Repository: vanderschaarlab/temporai (language: python)

Issue:
Set up config, data format, model, docs



Discussion / hints:

Fix patch:
diff --git a/LICENSE.txt b/LICENSE.txt
index 810fce6e..261eeb9e 100644
--- a/LICENSE.txt
+++ b/LICENSE.txt
@@ -1,621 +1,201 @@
-                    GNU GENERAL PUBLIC LICENSE
-                       Version 3, 29 June 2007
-
- Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
- Everyone is permitted to copy and distribute verbatim copies
- of this license document, but changing it is not allowed.
-
-                            Preamble
-
-  The GNU General Public License is a free, copyleft license for
-software and other kinds of works.
-
-  The licenses for most software and other practical works are designed
-to take away your freedom to share and change the works.  By contrast,
-the GNU General Public License is intended to guarantee your freedom to
-share and change all versions of a program--to make sure it remains free
-software for all its users.  We, the Free Software Foundation, use the
-GNU General Public License for most of our software; it applies also to
-any other work released this way by its authors.  You can apply it to
-your programs, too.
-
-  When we speak of free software, we are referring to freedom, not
-price.  Our General Public Licenses are designed to make sure that you
-have the freedom to distribute copies of free software (and charge for
-them if you wish), that you receive source code or can get it if you
-want it, that you can change the software or use pieces of it in new
-free programs, and that you know you can do these things.
-
-  To protect your rights, we need to prevent others from denying you
-these rights or asking you to surrender the rights.  Therefore, you have
-certain responsibilities if you distribute copies of the software, or if
-you modify it: responsibilities to respect the freedom of others.
-
-  For example, if you distribute copies of such a program, whether
-gratis or for a fee, you must pass on to the recipients the same
-freedoms that you received.  You must make sure that they, too, receive
-or can get the source code.  And you must show them these terms so they
-know their rights.
-
-  Developers that use the GNU GPL protect your rights with two steps:
-(1) assert copyright on the software, and (2) offer you this License
-giving you legal permission to copy, distribute and/or modify it.
-
-  For the developers' and authors' protection, the GPL clearly explains
-that there is no warranty for this free software.  For both users' and
-authors' sake, the GPL requires that modified versions be marked as
-changed, so that their problems will not be attributed erroneously to
-authors of previous versions.
-
-  Some devices are designed to deny users access to install or run
-modified versions of the software inside them, although the manufacturer
-can do so.  This is fundamentally incompatible with the aim of
-protecting users' freedom to change the software.  The systematic
-pattern of such abuse occurs in the area of products for individuals to
-use, which is precisely where it is most unacceptable.  Therefore, we
-have designed this version of the GPL to prohibit the practice for those
-products.  If such problems arise substantially in other domains, we
-stand ready to extend this provision to those domains in future versions
-of the GPL, as needed to protect the freedom of users.
-
-  Finally, every program is threatened constantly by software patents.
-States should not allow patents to restrict development and use of
-software on general-purpose computers, but in those that do, we wish to
-avoid the special danger that patents applied to a free program could
-make it effectively proprietary.  To prevent this, the GPL assures that
-patents cannot be used to render the program non-free.
-
-  The precise terms and conditions for copying, distribution and
-modification follow.
-
-                       TERMS AND CONDITIONS
-
-  0. Definitions.
-
-  "This License" refers to version 3 of the GNU General Public License.
-
-  "Copyright" also means copyright-like laws that apply to other kinds of
-works, such as semiconductor masks.
-
-  "The Program" refers to any copyrightable work licensed under this
-License.  Each licensee is addressed as "you".  "Licensees" and
-"recipients" may be individuals or organizations.
-
-  To "modify" a work means to copy from or adapt all or part of the work
-in a fashion requiring copyright permission, other than the making of an
-exact copy.  The resulting work is called a "modified version" of the
-earlier work or a work "based on" the earlier work.
-
-  A "covered work" means either the unmodified Program or a work based
-on the Program.
-
-  To "propagate" a work means to do anything with it that, without
-permission, would make you directly or secondarily liable for
-infringement under applicable copyright law, except executing it on a
-computer or modifying a private copy.  Propagation includes copying,
-distribution (with or without modification), making available to the
-public, and in some countries other activities as well.
-
-  To "convey" a work means any kind of propagation that enables other
-parties to make or receive copies.  Mere interaction with a user through
-a computer network, with no transfer of a copy, is not conveying.
-
-  An interactive user interface displays "Appropriate Legal Notices"
-to the extent that it includes a convenient and prominently visible
-feature that (1) displays an appropriate copyright notice, and (2)
-tells the user that there is no warranty for the work (except to the
-extent that warranties are provided), that licensees may convey the
-work under this License, and how to view a copy of this License.  If
-the interface presents a list of user commands or options, such as a
-menu, a prominent item in the list meets this criterion.
-
-  1. Source Code.
-
-  The "source code" for a work means the preferred form of the work
-for making modifications to it.  "Object code" means any non-source
-form of a work.
-
-  A "Standard Interface" means an interface that either is an official
-standard defined by a recognized standards body, or, in the case of
-interfaces specified for a particular programming language, one that
-is widely used among developers working in that language.
-
-  The "System Libraries" of an executable work include anything, other
-than the work as a whole, that (a) is included in the normal form of
-packaging a Major Component, but which is not part of that Major
-Component, and (b) serves only to enable use of the work with that
-Major Component, or to implement a Standard Interface for which an
-implementation is available to the public in source code form.  A
-"Major Component", in this context, means a major essential component
-(kernel, window system, and so on) of the specific operating system
-(if any) on which the executable work runs, or a compiler used to
-produce the work, or an object code interpreter used to run it.
-
-  The "Corresponding Source" for a work in object code form means all
-the source code needed to generate, install, and (for an executable
-work) run the object code and to modify the work, including scripts to
-control those activities.  However, it does not include the work's
-System Libraries, or general-purpose tools or generally available free
-programs which are used unmodified in performing those activities but
-which are not part of the work.  For example, Corresponding Source
-includes interface definition files associated with source files for
-the work, and the source code for shared libraries and dynamically
-linked subprograms that the work is specifically designed to require,
-such as by intimate data communication or control flow between those
-subprograms and other parts of the work.
-
-  The Corresponding Source need not include anything that users
-can regenerate automatically from other parts of the Corresponding
-Source.
-
-  The Corresponding Source for a work in source code form is that
-same work.
-
-  2. Basic Permissions.
-
-  All rights granted under this License are granted for the term of
-copyright on the Program, and are irrevocable provided the stated
-conditions are met.  This License explicitly affirms your unlimited
-permission to run the unmodified Program.  The output from running a
-covered work is covered by this License only if the output, given its
-content, constitutes a covered work.  This License acknowledges your
-rights of fair use or other equivalent, as provided by copyright law.
-
-  You may make, run and propagate covered works that you do not
-convey, without conditions so long as your license otherwise remains
-in force.  You may convey covered works to others for the sole purpose
-of having them make modifications exclusively for you, or provide you
-with facilities for running those works, provided that you comply with
-the terms of this License in conveying all material for which you do
-not control copyright.  Those thus making or running the covered works
-for you must do so exclusively on your behalf, under your direction
-and control, on terms that prohibit them from making any copies of
-your copyrighted material outside their relationship with you.
-
-  Conveying under any other circumstances is permitted solely under
-the conditions stated below.  Sublicensing is not allowed; section 10
-makes it unnecessary.
-
-  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
-
-  No covered work shall be deemed part of an effective technological
-measure under any applicable law fulfilling obligations under article
-11 of the WIPO copyright treaty adopted on 20 December 1996, or
-similar laws prohibiting or restricting circumvention of such
-measures.
-
-  When you convey a covered work, you waive any legal power to forbid
-circumvention of technological measures to the extent such circumvention
-is effected by exercising rights under this License with respect to
-the covered work, and you disclaim any intention to limit operation or
-modification of the work as a means of enforcing, against the work's
-users, your or third parties' legal rights to forbid circumvention of
-technological measures.
-
-  4. Conveying Verbatim Copies.
-
-  You may convey verbatim copies of the Program's source code as you
-receive it, in any medium, provided that you conspicuously and
-appropriately publish on each copy an appropriate copyright notice;
-keep intact all notices stating that this License and any
-non-permissive terms added in accord with section 7 apply to the code;
-keep intact all notices of the absence of any warranty; and give all
-recipients a copy of this License along with the Program.
-
-  You may charge any price or no price for each copy that you convey,
-and you may offer support or warranty protection for a fee.
-
-  5. Conveying Modified Source Versions.
-
-  You may convey a work based on the Program, or the modifications to
-produce it from the Program, in the form of source code under the
-terms of section 4, provided that you also meet all of these conditions:
-
-    a) The work must carry prominent notices stating that you modified
-    it, and giving a relevant date.
-
-    b) The work must carry prominent notices stating that it is
-    released under this License and any conditions added under section
-    7.  This requirement modifies the requirement in section 4 to
-    "keep intact all notices".
-
-    c) You must license the entire work, as a whole, under this
-    License to anyone who comes into possession of a copy.  This
-    License will therefore apply, along with any applicable section 7
-    additional terms, to the whole of the work, and all its parts,
-    regardless of how they are packaged.  This License gives no
-    permission to license the work in any other way, but it does not
-    invalidate such permission if you have separately received it.
-
-    d) If the work has interactive user interfaces, each must display
-    Appropriate Legal Notices; however, if the Program has interactive
-    interfaces that do not display Appropriate Legal Notices, your
-    work need not make them do so.
-
-  A compilation of a covered work with other separate and independent
-works, which are not by their nature extensions of the covered work,
-and which are not combined with it such as to form a larger program,
-in or on a volume of a storage or distribution medium, is called an
-"aggregate" if the compilation and its resulting copyright are not
-used to limit the access or legal rights of the compilation's users
-beyond what the individual works permit.  Inclusion of a covered work
-in an aggregate does not cause this License to apply to the other
-parts of the aggregate.
-
-  6. Conveying Non-Source Forms.
-
-  You may convey a covered work in object code form under the terms
-of sections 4 and 5, provided that you also convey the
-machine-readable Corresponding Source under the terms of this License,
-in one of these ways:
-
-    a) Convey the object code in, or embodied in, a physical product
-    (including a physical distribution medium), accompanied by the
-    Corresponding Source fixed on a durable physical medium
-    customarily used for software interchange.
-
-    b) Convey the object code in, or embodied in, a physical product
-    (including a physical distribution medium), accompanied by a
-    written offer, valid for at least three years and valid for as
-    long as you offer spare parts or customer support for that product
-    model, to give anyone who possesses the object code either (1) a
-    copy of the Corresponding Source for all the software in the
-    product that is covered by this License, on a durable physical
-    medium customarily used for software interchange, for a price no
-    more than your reasonable cost of physically performing this
-    conveying of source, or (2) access to copy the
-    Corresponding Source from a network server at no charge.
-
-    c) Convey individual copies of the object code with a copy of the
-    written offer to provide the Corresponding Source.  This
-    alternative is allowed only occasionally and noncommercially, and
-    only if you received the object code with such an offer, in accord
-    with subsection 6b.
-
-    d) Convey the object code by offering access from a designated
-    place (gratis or for a charge), and offer equivalent access to the
-    Corresponding Source in the same way through the same place at no
-    further charge.  You need not require recipients to copy the
-    Corresponding Source along with the object code.  If the place to
-    copy the object code is a network server, the Corresponding Source
-    may be on a different server (operated by you or a third party)
-    that supports equivalent copying facilities, provided you maintain
-    clear directions next to the object code saying where to find the
-    Corresponding Source.  Regardless of what server hosts the
-    Corresponding Source, you remain obligated to ensure that it is
-    available for as long as needed to satisfy these requirements.
-
-    e) Convey the object code using peer-to-peer transmission, provided
-    you inform other peers where the object code and Corresponding
-    Source of the work are being offered to the general public at no
-    charge under subsection 6d.
-
-  A separable portion of the object code, whose source code is excluded
-from the Corresponding Source as a System Library, need not be
-included in conveying the object code work.
-
-  A "User Product" is either (1) a "consumer product", which means any
-tangible personal property which is normally used for personal, family,
-or household purposes, or (2) anything designed or sold for incorporation
-into a dwelling.  In determining whether a product is a consumer product,
-doubtful cases shall be resolved in favor of coverage.  For a particular
-product received by a particular user, "normally used" refers to a
-typical or common use of that class of product, regardless of the status
-of the particular user or of the way in which the particular user
-actually uses, or expects or is expected to use, the product.  A product
-is a consumer product regardless of whether the product has substantial
-commercial, industrial or non-consumer uses, unless such uses represent
-the only significant mode of use of the product.
-
-  "Installation Information" for a User Product means any methods,
-procedures, authorization keys, or other information required to install
-and execute modified versions of a covered work in that User Product from
-a modified version of its Corresponding Source.  The information must
-suffice to ensure that the continued functioning of the modified object
-code is in no case prevented or interfered with solely because
-modification has been made.
-
-  If you convey an object code work under this section in, or with, or
-specifically for use in, a User Product, and the conveying occurs as
-part of a transaction in which the right of possession and use of the
-User Product is transferred to the recipient in perpetuity or for a
-fixed term (regardless of how the transaction is characterized), the
-Corresponding Source conveyed under this section must be accompanied
-by the Installation Information.  But this requirement does not apply
-if neither you nor any third party retains the ability to install
-modified object code on the User Product (for example, the work has
-been installed in ROM).
-
-  The requirement to provide Installation Information does not include a
-requirement to continue to provide support service, warranty, or updates
-for a work that has been modified or installed by the recipient, or for
-the User Product in which it has been modified or installed.  Access to a
-network may be denied when the modification itself materially and
-adversely affects the operation of the network or violates the rules and
-protocols for communication across the network.
-
-  Corresponding Source conveyed, and Installation Information provided,
-in accord with this section must be in a format that is publicly
-documented (and with an implementation available to the public in
-source code form), and must require no special password or key for
-unpacking, reading or copying.
-
-  7. Additional Terms.
-
-  "Additional permissions" are terms that supplement the terms of this
-License by making exceptions from one or more of its conditions.
-Additional permissions that are applicable to the entire Program shall
-be treated as though they were included in this License, to the extent
-that they are valid under applicable law.  If additional permissions
-apply only to part of the Program, that part may be used separately
-under those permissions, but the entire Program remains governed by
-this License without regard to the additional permissions.
-
-  When you convey a copy of a covered work, you may at your option
-remove any additional permissions from that copy, or from any part of
-it.  (Additional permissions may be written to require their own
-removal in certain cases when you modify the work.)  You may place
-additional permissions on material, added by you to a covered work,
-for which you have or can give appropriate copyright permission.
-
-  Notwithstanding any other provision of this License, for material you
-add to a covered work, you may (if authorized by the copyright holders of
-that material) supplement the terms of this License with terms:
-
-    a) Disclaiming warranty or limiting liability differently from the
-    terms of sections 15 and 16 of this License; or
-
-    b) Requiring preservation of specified reasonable legal notices or
-    author attributions in that material or in the Appropriate Legal
-    Notices displayed by works containing it; or
-
-    c) Prohibiting misrepresentation of the origin of that material, or
-    requiring that modified versions of such material be marked in
-    reasonable ways as different from the original version; or
-
-    d) Limiting the use for publicity purposes of names of licensors or
-    authors of the material; or
-
-    e) Declining to grant rights under trademark law for use of some
-    trade names, trademarks, or service marks; or
-
-    f) Requiring indemnification of licensors and authors of that
-    material by anyone who conveys the material (or modified versions of
-    it) with contractual assumptions of liability to the recipient, for
-    any liability that these contractual assumptions directly impose on
-    those licensors and authors.
-
-  All other non-permissive additional terms are considered "further
-restrictions" within the meaning of section 10.  If the Program as you
-received it, or any part of it, contains a notice stating that it is
-governed by this License along with a term that is a further
-restriction, you may remove that term.  If a license document contains
-a further restriction but permits relicensing or conveying under this
-License, you may add to a covered work material governed by the terms
-of that license document, provided that the further restriction does
-not survive such relicensing or conveying.
-
-  If you add terms to a covered work in accord with this section, you
-must place, in the relevant source files, a statement of the
-additional terms that apply to those files, or a notice indicating
-where to find the applicable terms.
-
-  Additional terms, permissive or non-permissive, may be stated in the
-form of a separately written license, or stated as exceptions;
-the above requirements apply either way.
-
-  8. Termination.
-
-  You may not propagate or modify a covered work except as expressly
-provided under this License.  Any attempt otherwise to propagate or
-modify it is void, and will automatically terminate your rights under
-this License (including any patent licenses granted under the third
-paragraph of section 11).
-
-  However, if you cease all violation of this License, then your
-license from a particular copyright holder is reinstated (a)
-provisionally, unless and until the copyright holder explicitly and
-finally terminates your license, and (b) permanently, if the copyright
-holder fails to notify you of the violation by some reasonable means
-prior to 60 days after the cessation.
-
-  Moreover, your license from a particular copyright holder is
-reinstated permanently if the copyright holder notifies you of the
-violation by some reasonable means, this is the first time you have
-received notice of violation of this License (for any work) from that
-copyright holder, and you cure the violation prior to 30 days after
-your receipt of the notice.
-
-  Termination of your rights under this section does not terminate the
-licenses of parties who have received copies or rights from you under
-this License.  If your rights have been terminated and not permanently
-reinstated, you do not qualify to receive new licenses for the same
-material under section 10.
-
-  9. Acceptance Not Required for Having Copies.
-
-  You are not required to accept this License in order to receive or
-run a copy of the Program.  Ancillary propagation of a covered work
-occurring solely as a consequence of using peer-to-peer transmission
-to receive a copy likewise does not require acceptance.  However,
-nothing other than this License grants you permission to propagate or
-modify any covered work.  These actions infringe copyright if you do
-not accept this License.  Therefore, by modifying or propagating a
-covered work, you indicate your acceptance of this License to do so.
-
-  10. Automatic Licensing of Downstream Recipients.
-
-  Each time you convey a covered work, the recipient automatically
-receives a license from the original licensors, to run, modify and
-propagate that work, subject to this License.  You are not responsible
-for enforcing compliance by third parties with this License.
-
-  An "entity transaction" is a transaction transferring control of an
-organization, or substantially all assets of one, or subdividing an
-organization, or merging organizations.  If propagation of a covered
-work results from an entity transaction, each party to that
-transaction who receives a copy of the work also receives whatever
-licenses to the work the party's predecessor in interest had or could
-give under the previous paragraph, plus a right to possession of the
-Corresponding Source of the work from the predecessor in interest, if
-the predecessor has it or can get it with reasonable efforts.
-
-  You may not impose any further restrictions on the exercise of the
-rights granted or affirmed under this License.  For example, you may
-not impose a license fee, royalty, or other charge for exercise of
-rights granted under this License, and you may not initiate litigation
-(including a cross-claim or counterclaim in a lawsuit) alleging that
-any patent claim is infringed by making, using, selling, offering for
-sale, or importing the Program or any portion of it.
-
-  11. Patents.
-
-  A "contributor" is a copyright holder who authorizes use under this
-License of the Program or a work on which the Program is based.  The
-work thus licensed is called the contributor's "contributor version".
-
-  A contributor's "essential patent claims" are all patent claims
-owned or controlled by the contributor, whether already acquired or
-hereafter acquired, that would be infringed by some manner, permitted
-by this License, of making, using, or selling its contributor version,
-but do not include claims that would be infringed only as a
-consequence of further modification of the contributor version.  For
-purposes of this definition, "control" includes the right to grant
-patent sublicenses in a manner consistent with the requirements of
-this License.
-
-  Each contributor grants you a non-exclusive, worldwide, royalty-free
-patent license under the contributor's essential patent claims, to
-make, use, sell, offer for sale, import and otherwise run, modify and
-propagate the contents of its contributor version.
-
-  In the following three paragraphs, a "patent license" is any express
-agreement or commitment, however denominated, not to enforce a patent
-(such as an express permission to practice a patent or covenant not to
-sue for patent infringement).  To "grant" such a patent license to a
-party means to make such an agreement or commitment not to enforce a
-patent against the party.
-
-  If you convey a covered work, knowingly relying on a patent license,
-and the Corresponding Source of the work is not available for anyone
-to copy, free of charge and under the terms of this License, through a
-publicly available network server or other readily accessible means,
-then you must either (1) cause the Corresponding Source to be so
-available, or (2) arrange to deprive yourself of the benefit of the
-patent license for this particular work, or (3) arrange, in a manner
-consistent with the requirements of this License, to extend the patent
-license to downstream recipients.  "Knowingly relying" means you have
-actual knowledge that, but for the patent license, your conveying the
-covered work in a country, or your recipient's use of the covered work
-in a country, would infringe one or more identifiable patents in that
-country that you have reason to believe are valid.
-
-  If, pursuant to or in connection with a single transaction or
-arrangement, you convey, or propagate by procuring conveyance of, a
-covered work, and grant a patent license to some of the parties
-receiving the covered work authorizing them to use, propagate, modify
-or convey a specific copy of the covered work, then the patent license
-you grant is automatically extended to all recipients of the covered
-work and works based on it.
-
-  A patent license is "discriminatory" if it does not include within
-the scope of its coverage, prohibits the exercise of, or is
-conditioned on the non-exercise of one or more of the rights that are
-specifically granted under this License.  You may not convey a covered
-work if you are a party to an arrangement with a third party that is
-in the business of distributing software, under which you make payment
-to the third party based on the extent of your activity of conveying
-the work, and under which the third party grants, to any of the
-parties who would receive the covered work from you, a discriminatory
-patent license (a) in connection with copies of the covered work
-conveyed by you (or copies made from those copies), or (b) primarily
-for and in connection with specific products or compilations that
-contain the covered work, unless you entered into that arrangement,
-or that patent license was granted, prior to 28 March 2007.
-
-  Nothing in this License shall be construed as excluding or limiting
-any implied license or other defenses to infringement that may
-otherwise be available to you under applicable patent law.
-
-  12. No Surrender of Others' Freedom.
-
-  If conditions are imposed on you (whether by court order, agreement or
-otherwise) that contradict the conditions of this License, they do not
-excuse you from the conditions of this License.  If you cannot convey a
-covered work so as to satisfy simultaneously your obligations under this
-License and any other pertinent obligations, then as a consequence you may
-not convey it at all.  For example, if you agree to terms that obligate you
-to collect a royalty for further conveying from those to whom you convey
-the Program, the only way you could satisfy both those terms and this
-License would be to refrain entirely from conveying the Program.
-
-  13. Use with the GNU Affero General Public License.
-
-  Notwithstanding any other provision of this License, you have
-permission to link or combine any covered work with a work licensed
-under version 3 of the GNU Affero General Public License into a single
-combined work, and to convey the resulting work.  The terms of this
-License will continue to apply to the part which is the covered work,
-but the special requirements of the GNU Affero General Public License,
-section 13, concerning interaction through a network will apply to the
-combination as such.
-
-  14. Revised Versions of this License.
-
-  The Free Software Foundation may publish revised and/or new versions of
-the GNU General Public License from time to time.  Such new versions will
-be similar in spirit to the present version, but may differ in detail to
-address new problems or concerns.
-
-  Each version is given a distinguishing version number.  If the
-Program specifies that a certain numbered version of the GNU General
-Public License "or any later version" applies to it, you have the
-option of following the terms and conditions either of that numbered
-version or of any later version published by the Free Software
-Foundation.  If the Program does not specify a version number of the
-GNU General Public License, you may choose any version ever published
-by the Free Software Foundation.
-
-  If the Program specifies that a proxy can decide which future
-versions of the GNU General Public License can be used, that proxy's
-public statement of acceptance of a version permanently authorizes you
-to choose that version for the Program.
-
-  Later license versions may give you additional or different
-permissions.  However, no additional obligations are imposed on any
-author or copyright holder as a result of your choosing to follow a
-later version.
-
-  15. Disclaimer of Warranty.
-
-  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
-APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
-HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
-OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
-THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
-PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
-IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
-ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
-
-  16. Limitation of Liability.
-
-  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
-WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
-THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
-GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
-USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
-DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
-PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
-EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
-SUCH DAMAGES.
-
-  17. Interpretation of Sections 15 and 16.
-
-  If the disclaimer of warranty and limitation of liability provided
-above cannot be given local legal effect according to their terms,
-reviewing courts shall apply local law that most closely approximates
-an absolute waiver of all civil liability in connection with the
-Program, unless a warranty or assumption of liability accompanies a
-copy of the Program in return for a fee.
-
-                     END OF TERMS AND CONDITIONS
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/setup.cfg b/setup.cfg
index 8da612ff..13cb7bfe 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -8,7 +8,7 @@ name = temporai
 description = TemporAI: ML-centric Toolkit for Medical Time Series
 author = Evgeny Saveliev
 author_email = e.s.saveliev@gmail.com
-license = GPL-3.0-or-later
+license = Apache-2.0 license
 license_files = LICENSE.txt
 long_description = file: README.md
 long_description_content_type = text/markdown; charset=UTF-8; variant=GFM
@@ -55,6 +55,7 @@ install_requires =
     pandas >=1
     pandera >=0.13
     pydantic >=1, <2
+    rich
 
 
 [options.packages.find]
@@ -74,9 +75,11 @@ tempor =
 
 # Add here test requirements (semicolon/line-separated)
 testing =
-    setuptools
+    jupyter
+    notebook
     pytest
     pytest-cov
+    setuptools
 
 [options.entry_points]
 # Add here console scripts like:
@@ -118,6 +121,7 @@ exclude =
 max-line-length = 120
 disable = R, C
 generated-members = torch.*
+extension-pkg-whitelist = pydantic
 
 [pylint.messages_control]
 disable = C0330, C0326, fixme, c-extension-no-member
diff --git a/src/tempor/config/conf/tempor/config.yaml b/src/tempor/config/conf/tempor/config.yaml
index 5cff15ae..3eecd6f1 100644
--- a/src/tempor/config/conf/tempor/config.yaml
+++ b/src/tempor/config/conf/tempor/config.yaml
@@ -1,6 +1,6 @@
 logging:
   mode: LIBRARY  # {LIBRARY, SCRIPT}
-  level: DEBUG
+  level: INFO
   diagnose: false
   backtrace: true
   file_log: true
diff --git a/src/tempor/core/__init__.py b/src/tempor/core/__init__.py
index cca736e7..8484ffbc 100644
--- a/src/tempor/core/__init__.py
+++ b/src/tempor/core/__init__.py
@@ -1,4 +1,4 @@
-from . import _supports_impl as supports_impl
+from . import supports_impl
 from ._register_method_decorator import RegisterMethodDecorator
 
 __all__ = [
diff --git a/src/tempor/core/_supports_impl.py b/src/tempor/core/_supports_impl.py
index 34cee27e..47666dfb 100644
--- a/src/tempor/core/_supports_impl.py
+++ b/src/tempor/core/_supports_impl.py
@@ -1,11 +1,11 @@
-from abc import ABC, abstractmethod
+import abc
 from typing import Dict, Generic, Tuple, TypeVar
 
 SupportsT = TypeVar("SupportsT")
 ImplementationT = TypeVar("ImplementationT")
 
 
-class SupportsImplementations(Generic[SupportsT, ImplementationT], ABC):
+class SupportsImplementations(Generic[SupportsT, ImplementationT], abc.ABC):
     def __init__(self) -> None:
         super().__init__()
         self._implementations: Dict[SupportsT, ImplementationT] = self._register_implementations()
@@ -26,11 +26,11 @@ def dispatch_to_implementation(self, key: SupportsT) -> ImplementationT:
         return self._implementations[key]
 
     @property
-    @abstractmethod
+    @abc.abstractmethod
     def supports_implementations_for(self) -> Tuple[SupportsT, ...]:  # pragma: no cover
         ...
 
-    @abstractmethod
+    @abc.abstractmethod
     def _register_implementations(self) -> Dict[SupportsT, ImplementationT]:  # pragma: no cover
         ...
 
diff --git a/src/tempor/core/pydantic_utils.py b/src/tempor/core/pydantic_utils.py
new file mode 100644
index 00000000..693f2c6b
--- /dev/null
+++ b/src/tempor/core/pydantic_utils.py
@@ -0,0 +1,16 @@
+from typing import Dict, Optional
+
+
+def exclusive_args(
+    values: Dict,
+    arg1: str,
+    arg2: str,
+    arg1_friendly_name: Optional[str] = None,
+    arg2_friendly_name: Optional[str] = None,
+) -> None:
+    arg1_value = values.get(arg1, None)
+    arg2_value = values.get(arg2, None)
+    arg1_name = arg1_friendly_name if arg1_friendly_name else f"`{arg1}`"
+    arg2_name = arg2_friendly_name if arg2_friendly_name else f"`{arg2}`"
+    if arg1_value is not None and arg2_value is not None:
+        raise ValueError(f"Must provide either {arg1_name} or {arg2_name} but not both")
diff --git a/src/tempor/core/requirements.py b/src/tempor/core/requirements.py
new file mode 100644
index 00000000..fa9895c1
--- /dev/null
+++ b/src/tempor/core/requirements.py
@@ -0,0 +1,59 @@
+import abc
+import enum
+from typing import Any, ClassVar, Sequence, get_type_hints
+
+import pydantic
+import rich.pretty
+
+
+class RequirementCategory(enum.Enum):
+    DATA_CONTAINER = enum.auto()
+    DATA_BUNDLE = enum.auto()
+    MODEL = enum.auto()
+
+
+class Requirement(abc.ABC):
+    definition: Any
+
+    name: ClassVar[str] = None  # type: ignore
+    validator_methods: ClassVar[Sequence[Any]] = tuple()
+
+    def __init__(self, definition) -> None:
+        validator = pydantic.create_model(
+            f"{self.__class__.__name__}Validator",
+            __validators__={clsmtd.__func__.__name__: clsmtd for clsmtd in self.validator_methods},  # type: ignore
+            name=(str, ...),
+            definition=(get_type_hints(self)["definition"], ...),
+        )
+        validator(name=self.name, definition=definition)
+        self.definition = definition
+
+    @property
+    @abc.abstractmethod
+    def requirement_category(self) -> RequirementCategory:  # pragma: no cover
+        ...
+
+    def __rich_repr__(self):
+        yield "definition", self.definition
+
+    def __repr__(self) -> str:
+        return rich.pretty.pretty_repr(self)
+
+
+class RequirementValidator(abc.ABC):
+    @property
+    @abc.abstractmethod
+    def supported_requirement_category(self) -> RequirementCategory:  # pragma: no cover
+        ...
+
+    def validate(self, target: Any, *, requirements: Sequence[Requirement], **kwargs):  # pragma: no cover
+        for r in requirements:
+            if r.requirement_category != self.supported_requirement_category:
+                raise ValueError(
+                    f"{self.__class__.__name__} does not support requirement category {r.requirement_category}"
+                )
+        self._validate(target=target, requirements=requirements, **kwargs)
+
+    @abc.abstractmethod
+    def _validate(self, *args, **kwargs):  # pragma: no cover
+        ...
diff --git a/src/tempor/core/supports_impl.py b/src/tempor/core/supports_impl.py
new file mode 100644
index 00000000..af6f4237
--- /dev/null
+++ b/src/tempor/core/supports_impl.py
@@ -0,0 +1,1 @@
+from ._supports_impl import *  # noqa  # pylint: disable=W
diff --git a/src/tempor/core/utils.py b/src/tempor/core/utils.py
new file mode 100644
index 00000000..8d42ebfe
--- /dev/null
+++ b/src/tempor/core/utils.py
@@ -0,0 +1,22 @@
+import enum
+
+
+def get_class_full_name(o: object):
+    # See: https://stackoverflow.com/a/2020083
+    class_ = o.__class__
+    module = class_.__module__
+    if module == "builtins":
+        return class_.__qualname__  # avoid outputs like "builtins.str"
+    return module + "." + class_.__qualname__
+
+
+def get_enum_name(enum_: enum.Enum) -> str:
+    return enum_.name.lower()
+
+
+class RichReprStrPassthrough:
+    def __init__(self, string: str) -> None:
+        self.string = string
+
+    def __repr__(self) -> str:
+        return self.string
diff --git a/src/tempor/data/__init__.py b/src/tempor/data/__init__.py
index 80de7e20..e69de29b 100644
--- a/src/tempor/data/__init__.py
+++ b/src/tempor/data/__init__.py
@@ -1,34 +0,0 @@
-from ._check_data_container_def import CheckDataContainerDefinition
-from ._settings import (
-    CONTAINER_CLASS_TO_CONTAINER_FLAVORS,
-    DATA_CATEGORY_TO_CONTAINER_FLAVORS,
-    DATA_SETTINGS,
-    DEFAULT_CONTAINER_FLAVOR,
-    DataSettings,
-)
-from ._supports_container import (
-    DataContainerDef,
-    EventSupportsContainer,
-    StaticSupportsContainer,
-    SupportsContainer,
-    TimeSeriesSupportsContainer,
-)
-from ._types import ContainerFlavor, DataCategory, DataContainer, Dtype
-
-__all__ = [
-    "CheckDataContainerDefinition",
-    "CONTAINER_CLASS_TO_CONTAINER_FLAVORS",
-    "ContainerFlavor",
-    "DEFAULT_CONTAINER_FLAVOR",
-    "DATA_CATEGORY_TO_CONTAINER_FLAVORS",
-    "DATA_SETTINGS",
-    "DataCategory",
-    "DataContainer",
-    "DataContainerDef",
-    "DataSettings",
-    "Dtype",
-    "EventSupportsContainer",
-    "StaticSupportsContainer",
-    "SupportsContainer",
-    "TimeSeriesSupportsContainer",
-]
diff --git a/src/tempor/data/_check_data_container_def.py b/src/tempor/data/_check_data_container_def.py
deleted file mode 100644
index 4a2743f4..00000000
--- a/src/tempor/data/_check_data_container_def.py
+++ /dev/null
@@ -1,48 +0,0 @@
-import typing
-from typing import Type
-
-import pydantic
-import pydantic.dataclasses
-
-from . import _settings as settings
-from . import _types as types
-
-
-@pydantic.dataclasses.dataclass(frozen=True)
-class CheckDataContainerDefinition:
-    data_category: types.DataCategory
-    container_class: Type[types.DataContainer]
-    container_flavor: types.ContainerFlavor
-
-    @pydantic.validator("container_class")
-    def check_data_container_type_in_possible_types(cls, v):  # pylint: disable=no-self-argument
-        possible_types = typing.get_args(types.DataContainer)
-        if not issubclass(v, possible_types):
-            raise TypeError(f"`container_class` must be one of the data container types {possible_types} but was {v}")
-        else:
-            return v
-
-    @pydantic.root_validator(skip_on_failure=True)
-    def container_class_matches_data_category(cls, values):  # pylint: disable=no-self-argument
-        data_category = values.get("data_category")
-        container_class = values.get("container_class")
-        container_flavor = values.get("container_flavor")
-        if container_flavor not in settings.DATA_CATEGORY_TO_CONTAINER_FLAVORS[data_category]:
-            raise ValueError(
-                f"Data category {data_category} supports container flavors "
-                f"{list(settings.DATA_CATEGORY_TO_CONTAINER_FLAVORS[data_category])} but container flavor "
-                f"{container_flavor} found"
-            )
-        if container_flavor not in settings.CONTAINER_CLASS_TO_CONTAINER_FLAVORS[container_class]:
-            raise ValueError(
-                f"Data container class {container_class} supports container flavors "
-                f"{list(settings.CONTAINER_CLASS_TO_CONTAINER_FLAVORS[container_class])} but container flavor "
-                f"{container_flavor} found"
-            )
-        if (data_category, container_class) not in settings.DEFAULT_CONTAINER_FLAVOR.keys():
-            raise ValueError(
-                "Default container flavor for the (data category, container class) combination "
-                f"{(data_category, container_class)} has not been set in settings, defaults are as follows:\n"
-                f"{settings.DEFAULT_CONTAINER_FLAVOR}"
-            )
-        return values
diff --git a/src/tempor/data/_settings.py b/src/tempor/data/_settings.py
deleted file mode 100644
index ef5e1e07..00000000
--- a/src/tempor/data/_settings.py
+++ /dev/null
@@ -1,48 +0,0 @@
-from typing import ClassVar, Dict, Set, Tuple, Type
-
-import pandas as pd
-import pydantic.dataclasses
-
-from . import _types as types
-
-
-@pydantic.dataclasses.dataclass(frozen=True)
-class DataSettings:
-    value_dtypes: ClassVar[Set[types.Dtype]] = {bool, int, float, "category"}
-    sample_index_dtypes: ClassVar[Set[types.Dtype]] = {int, str}
-    time_index_dtypes: ClassVar[Set[types.Dtype]] = {"datetime", int}
-    feature_index_dtypes: ClassVar[Set[types.Dtype]] = {str}
-    sample_index_unique: ClassVar[bool] = True
-    sample_timestep_index_unique: ClassVar[bool] = True
-    sample_index_nullable: ClassVar[bool] = False
-    time_index_nullable: ClassVar[bool] = False
-    feature_index_nullable: ClassVar[bool] = False
-    values_nullable: ClassVar[bool] = True
-    sample_index_name: str = "sample_idx"
-    time_index_name: str = "time_idx"
-
-
-DATA_SETTINGS = DataSettings()
-
-
-DATA_CATEGORY_TO_CONTAINER_FLAVORS: Dict[types.DataCategory, Set[types.ContainerFlavor]] = {
-    types.DataCategory.STATIC: {types.ContainerFlavor.DF_SAMPLE_X_FEATURE},
-    types.DataCategory.TIME_SERIES: {types.ContainerFlavor.DF_SAMPLE_TIMESTEP_X_FEATURE},
-    types.DataCategory.EVENT: {types.ContainerFlavor.DF_SAMPLE_TIME_X_EVENT},
-}
-
-
-CONTAINER_CLASS_TO_CONTAINER_FLAVORS: Dict[Type[types.DataContainer], Set[types.ContainerFlavor]] = {
-    pd.DataFrame: {
-        types.ContainerFlavor.DF_SAMPLE_X_FEATURE,
-        types.ContainerFlavor.DF_SAMPLE_TIMESTEP_X_FEATURE,
-        types.ContainerFlavor.DF_SAMPLE_TIME_X_EVENT,
-    },
-}
-
-
-DEFAULT_CONTAINER_FLAVOR: Dict[Tuple[types.DataCategory, type], types.ContainerFlavor] = {
-    (types.DataCategory.STATIC, pd.DataFrame): types.ContainerFlavor.DF_SAMPLE_X_FEATURE,
-    (types.DataCategory.TIME_SERIES, pd.DataFrame): types.ContainerFlavor.DF_SAMPLE_TIMESTEP_X_FEATURE,
-    (types.DataCategory.EVENT, pd.DataFrame): types.ContainerFlavor.DF_SAMPLE_TIME_X_EVENT,
-}
diff --git a/src/tempor/data/_supports_container.py b/src/tempor/data/_supports_container.py
deleted file mode 100644
index 58acc69d..00000000
--- a/src/tempor/data/_supports_container.py
+++ /dev/null
@@ -1,65 +0,0 @@
-from abc import ABC, abstractmethod
-from typing import Tuple
-
-import pandas as pd
-
-import tempor.data._check_data_container_def as check
-from tempor.core import supports_impl as si
-
-from . import _types as types
-
-DataContainerDef = Tuple[type, types.ContainerFlavor]
-
-
-class SupportsContainer(si.SupportsImplementations[DataContainerDef, si.ImplementationT], ABC):
-    def check_data_container_supported_types(self, data_container: types.DataContainer):
-        # Each derived class of `SupportsContainer` may choose to support only a subset of data container types.
-        # Hence this pydantic validator added to check that.
-        supports_container_defs: Tuple[DataContainerDef, ...] = self.supports_implementations_for
-        supports_containers = tuple(x[0] for x in supports_container_defs)
-        if not isinstance(data_container, supports_containers):
-            raise TypeError(
-                f"`data_container` must be one of the supported types {supports_containers} but was {type(data_container)}"
-            )
-
-    def __init__(self) -> None:
-        for container_class, container_flavor in self.supports_implementations_for:
-            check.CheckDataContainerDefinition(
-                data_category=self.data_category, container_class=container_class, container_flavor=container_flavor
-            )
-        super().__init__()
-
-    @property
-    @abstractmethod
-    def data_category(self) -> types.DataCategory:  # pragma: no cover
-        ...
-
-
-class StaticSupportsContainer(SupportsContainer[si.ImplementationT]):
-    @property
-    def data_category(self) -> types.DataCategory:
-        return types.DataCategory.STATIC
-
-    @property
-    def supports_implementations_for(self) -> Tuple[DataContainerDef, ...]:
-        return ((pd.DataFrame, types.ContainerFlavor.DF_SAMPLE_X_FEATURE),)
-
-
-class TimeSeriesSupportsContainer(SupportsContainer[si.ImplementationT]):
-    @property
-    def data_category(self) -> types.DataCategory:
-        return types.DataCategory.TIME_SERIES
-
-    @property
-    def supports_implementations_for(self) -> Tuple[DataContainerDef, ...]:
-        return ((pd.DataFrame, types.ContainerFlavor.DF_SAMPLE_TIMESTEP_X_FEATURE),)
-
-
-class EventSupportsContainer(SupportsContainer[si.ImplementationT]):
-    @property
-    def data_category(self) -> types.DataCategory:
-        return types.DataCategory.EVENT
-
-    @property
-    def supports_implementations_for(self) -> Tuple[DataContainerDef, ...]:
-        return ((pd.DataFrame, types.ContainerFlavor.DF_SAMPLE_TIME_X_EVENT),)
diff --git a/src/tempor/data/_types.py b/src/tempor/data/_types.py
deleted file mode 100644
index 1a2ba017..00000000
--- a/src/tempor/data/_types.py
+++ /dev/null
@@ -1,20 +0,0 @@
-import enum
-from typing import Literal, Union
-
-import numpy as np
-import pandas as pd
-
-DataContainer = Union[pd.DataFrame, np.ndarray]
-Dtype = Union[type, Literal["category", "datetime"]]
-
-
-class DataCategory(enum.Enum):
-    STATIC = enum.auto()
-    TIME_SERIES = enum.auto()
-    EVENT = enum.auto()
-
-
-class ContainerFlavor(enum.Enum):
-    DF_SAMPLE_X_FEATURE = enum.auto()
-    DF_SAMPLE_TIMESTEP_X_FEATURE = enum.auto()
-    DF_SAMPLE_TIME_X_EVENT = enum.auto()
diff --git a/src/tempor/data/data_typing.py b/src/tempor/data/data_typing.py
new file mode 100644
index 00000000..e9d2dc9f
--- /dev/null
+++ b/src/tempor/data/data_typing.py
@@ -0,0 +1,62 @@
+"""Types (and related code) for TemporAI data handling.
+"""
+
+import enum
+from typing import Dict, List, Literal, Tuple, Type, Union
+
+import numpy as np
+import pandas as pd
+
+DataContainer = Union[pd.DataFrame, np.ndarray]
+
+Dtype = Union[Type, Literal["category", "datetime"]]
+"""Type annotation to indicate dtypes. May be `Type`, e.g. `str`, `bool`, or one of the literals:
+`"category"`, `"datetime"`
+"""
+
+
+class DataModality(enum.Enum):
+    STATIC = enum.auto()
+    TIME_SERIES = enum.auto()
+    EVENT = enum.auto()
+
+
+# Check these match `DATA_SETTINGS` in `data.settings``.
+# - Sample index allowed dtypes:  (int, str).
+# - Feature index allowed dtypes: (str,).
+# - Time index allowed dtypes:    (pd.Timestamp, int, float).
+# NOTE: The ordering of unions will affect pydantic parsing. Earlier types in the ordering will be tried first.
+# See: https://docs.pydantic.dev/usage/types/#unions
+SampleIndex = Union[
+    List[int],
+    List[str],
+]
+FeatureIndex = List[str]
+TimeIndex = Union[
+    List[float],
+    List[int],
+    List[pd.Timestamp],
+]
+SampleToTimeIndexDict = Union[
+    Dict[int, TimeIndex],
+    Dict[str, TimeIndex],
+]
+SampleTimeIndexTuples = List[
+    Union[
+        Tuple[int, float],
+        Tuple[int, int],
+        Tuple[int, pd.Timestamp],
+        Tuple[str, float],
+        Tuple[str, int],
+        Tuple[str, pd.Timestamp],
+    ]
+]
+TimeIndexList = List[TimeIndex]
+
+
+class PredictiveTask(enum.Enum):
+    ONE_OFF_PREDICTION = enum.auto()
+    TEMPORAL_PREDICTION = enum.auto()
+    TIME_TO_EVENT_ANALYSIS = enum.auto()
+    ONE_OFF_TREATMENT_EFFECTS = enum.auto()
+    TEMPORAL_TREATMENT_EFFECTS = enum.auto()
diff --git a/src/tempor/data/dataset.py b/src/tempor/data/dataset.py
new file mode 100644
index 00000000..b026d272
--- /dev/null
+++ b/src/tempor/data/dataset.py
@@ -0,0 +1,307 @@
+# pylint: disable=unnecessary-ellipsis
+
+import abc
+import dataclasses
+from typing import ClassVar, Optional, Union
+
+import rich.pretty
+
+from tempor.core.utils import RichReprStrPassthrough
+from tempor.log import log_helpers
+
+from . import data_typing
+from . import predictive as pred
+from . import samples
+
+
+@dataclasses.dataclass(frozen=True)
+class _SampleIndexMismatchMsg:
+    static: ClassVar[str] = "`sample_index` of static samples did not match `sample_index` of time series samples"
+    targets: ClassVar[str] = "`sample_index` of targets did not match `sample_index` of time series samples"
+    treatments: ClassVar[str] = "`sample_index` of treatments did not match `sample_index` of time series samples"
+
+
+@dataclasses.dataclass(frozen=True)
+class _ExceptionMessages:
+    sample_index_mismatch: ClassVar[_SampleIndexMismatchMsg] = _SampleIndexMismatchMsg()
+
+
+EXCEPTION_MESSAGES = _ExceptionMessages()
+"""Reusable error messages for the module."""
+
+
+class Dataset(abc.ABC):
+    time_series: samples.TimeSeriesSamples
+    static: Optional[samples.StaticSamples]
+    predictive: Optional[pred.PredictiveTaskData]
+
+    def __init__(
+        self,
+        time_series: data_typing.DataContainer,
+        *,
+        static: Optional[data_typing.DataContainer] = None,
+        targets: Optional[data_typing.DataContainer] = None,
+        treatments: Optional[data_typing.DataContainer] = None,
+        **kwargs,  # pylint: disable=unused-argument
+    ) -> None:
+        """Base class representing a dataset used by TemporAI.
+
+        Initialize one of its derived classes (e.g. `OneOffPredictionDataset`, `TimeToEventAnalysisDataset` etc.)
+        depending on the type of task.
+
+        See also tutorial `tutorials/tutorial01_data_format.ipynb` for examples of use.
+
+        Args:
+            time_series (numpy.ndarray | pandas.DataFrame): Data representing time series covariates of the samples.
+            Will be initialized as `TimeSeriesSamples`.
+            static (numpy.ndarray | pandas.DataFrame, optional): Data representing static covariates of the samples.
+            Will be initialized as `StaticSamples`. Defaults to None.
+            targets (numpy.ndarray | pandas.DataFrame, optional): Data representing target (outcome) feature(s) of the
+            samples. Will be initialized as `{TimeSeries,Static,Event}Samples` depending on problem setting in the
+            derived class. Defaults to None.
+            treatments (numpy.ndarray | pandas.DataFrame, optional): Data representing treatment (intervention)
+            feature(s) of the samples. Will be initialized as `{TimeSeries,Static,Event}Samples` depending on problem
+            setting in the derived class. Defaults to None.
+        """
+        self.time_series = samples.TimeSeriesSamples(time_series)
+        self.static = samples.StaticSamples(static) if static is not None else None
+
+        if targets is not None:
+            self._init_predictive(targets=targets, treatments=treatments, **kwargs)
+        else:
+            self.predictive = None
+
+        self.validate()
+
+    def __rich_repr__(self):
+        yield "time_series", RichReprStrPassthrough(self.time_series.short_repr())
+        if self.static is not None:
+            yield "static", RichReprStrPassthrough(self.static.short_repr())
+        if self.predictive is not None:
+            yield "static", self.predictive
+
+    def __repr__(self) -> str:
+        return rich.pretty.pretty_repr(self)
+
+    @abc.abstractmethod
+    def _init_predictive(
+        self,
+        targets: data_typing.DataContainer,
+        treatments: Optional[data_typing.DataContainer] = None,
+        **kwargs,
+    ) -> None:  # pragma: no cover
+        """A method to initialize `self.predictive` in derived classes."""
+        ...
+
+    @property
+    def has_static(self) -> bool:
+        return self.static is not None
+
+    @property
+    def has_predictive_data(self) -> bool:
+        return self.predictive is not None
+
+    @property
+    def predictive_task(self) -> Union[data_typing.PredictiveTask, None]:
+        if self.predictive is not None:
+            return self.predictive.predictive_task
+        else:
+            return None
+
+    def validate(self) -> None:
+        """Validate integrity of the dataset."""
+        with log_helpers.exc_to_log("Dataset validation failed"):
+            if self.static is not None:
+                if self.static.sample_index() != self.time_series.sample_index():
+                    raise ValueError(EXCEPTION_MESSAGES.sample_index_mismatch.static)
+            self._validate()
+
+    @abc.abstractmethod
+    def _validate(self) -> None:  # pragma: no cover
+        ...
+
+
+# `Dataset`s corresponding to different tasks follow. More can be added to handle new Tasks.
+
+
+class OneOffPredictionDataset(Dataset):
+    predictive: pred.OneOffPredictionTaskData
+
+    def __init__(  # pylint: disable=useless-super-delegation
+        self,
+        time_series: data_typing.DataContainer,
+        *,
+        targets: data_typing.DataContainer,
+        static: Optional[data_typing.DataContainer] = None,
+        treatments: Optional[data_typing.DataContainer] = None,
+        **kwargs,
+    ) -> None:
+        """A `Dataset` subclass for the one-off prediction problem setting, see `Dataset` docs.
+
+        In this setting: `targets` are required, will be initialized as `StaticSamples`.
+        """
+        super().__init__(time_series=time_series, static=static, targets=targets, treatments=treatments, **kwargs)
+
+    def _init_predictive(
+        self,
+        targets: data_typing.DataContainer,
+        treatments: Optional[data_typing.DataContainer] = None,
+        **kwargs,
+    ) -> None:
+        if targets is None:
+            raise ValueError("One-off prediction task requires `targets`")
+        self.predictive = pred.OneOffPredictionTaskData(targets=targets, **kwargs)
+
+    def _validate(self) -> None:
+        if self.predictive.targets.sample_index() != self.time_series.sample_index():
+            raise ValueError(EXCEPTION_MESSAGES.sample_index_mismatch.targets)
+
+
+class TemporalPredictionDataset(Dataset):
+    predictive: pred.TemporalPredictionTaskData
+
+    def __init__(  # pylint: disable=useless-super-delegation
+        self,
+        time_series: data_typing.DataContainer,
+        *,
+        targets: data_typing.DataContainer,
+        static: Optional[data_typing.DataContainer] = None,
+        treatments: Optional[data_typing.DataContainer] = None,
+        **kwargs,
+    ) -> None:
+        """A `Dataset` subclass for the temporal prediction problem setting, see `Dataset` docs.
+
+        In this setting: `targets` are required, will be initialized as `TimeSeriesSamples`.
+        """
+        super().__init__(time_series=time_series, static=static, targets=targets, treatments=treatments, **kwargs)
+
+    def _init_predictive(
+        self,
+        targets: data_typing.DataContainer,
+        treatments: Optional[data_typing.DataContainer] = None,
+        **kwargs,
+    ) -> None:
+        if targets is None:
+            raise ValueError("Temporal prediction task requires `targets`")
+        self.predictive = pred.TemporalPredictionTaskData(targets=targets, **kwargs)
+
+    def _validate(self) -> None:
+        print(self.predictive.targets.sample_index())
+        if self.predictive.targets.sample_index() != self.time_series.sample_index():
+            raise ValueError(EXCEPTION_MESSAGES.sample_index_mismatch.targets)
+        # TODO: Possible check - check that .time_series and .predictive.targets have the same time_indexes.
+
+
+class TimeToEventAnalysisDataset(Dataset):
+    predictive: pred.TimeToEventAnalysisTaskData
+
+    def __init__(  # pylint: disable=useless-super-delegation
+        self,
+        time_series: data_typing.DataContainer,
+        *,
+        targets: data_typing.DataContainer,
+        static: Optional[data_typing.DataContainer] = None,
+        treatments: Optional[data_typing.DataContainer] = None,
+        **kwargs,
+    ) -> None:
+        """A `Dataset` subclass for the time-to-event analysis problem setting, see `Dataset` docs.
+
+        In this setting: `targets` are required, will be initialized as `EventSamples`.
+        """
+        super().__init__(time_series=time_series, static=static, targets=targets, treatments=treatments, **kwargs)
+
+    def _init_predictive(  # pylint: disable=useless-super-delegation
+        self,
+        targets: data_typing.DataContainer,
+        treatments: Optional[data_typing.DataContainer] = None,
+        **kwargs,
+    ) -> None:
+        if targets is None:
+            raise ValueError("Time-to-event analysis task requires `targets`")
+        self.predictive = pred.TimeToEventAnalysisTaskData(targets=targets, **kwargs)
+
+    def _validate(self) -> None:
+        if self.predictive.targets.sample_index() != self.time_series.sample_index():
+            raise ValueError(EXCEPTION_MESSAGES.sample_index_mismatch.targets)
+        # TODO: Possible checks - some checks on .time_series and .predictive.targets in terms of
+        # their relative position in time?
+
+
+class OneOffTreatmentEffectsDataset(Dataset):
+    predictive: pred.OneOffTreatmentEffectsTaskData
+
+    def __init__(  # pylint: disable=useless-super-delegation
+        self,
+        time_series: data_typing.DataContainer,
+        *,
+        targets: data_typing.DataContainer,
+        treatments: data_typing.DataContainer,
+        static: Optional[data_typing.DataContainer] = None,
+        **kwargs,
+    ) -> None:
+        """A `Dataset` subclass for the one-off treatment effects problem setting, see `Dataset` docs.
+
+        In this setting: `targets` are required, will be initialized as `TimeSeriesSamples`; `treatments` are required,
+        will be initialized as `EventSamples`.
+        """
+        super().__init__(time_series=time_series, static=static, targets=targets, treatments=treatments, **kwargs)
+
+    def _init_predictive(
+        self,
+        targets: data_typing.DataContainer,
+        treatments: Optional[data_typing.DataContainer] = None,
+        **kwargs,
+    ) -> None:
+        if targets is None:
+            raise ValueError("On-off treatment effects task requires `targets`")
+        if treatments is None:
+            raise ValueError("On-off treatment effects task requires `treatments`")
+        self.predictive = pred.OneOffTreatmentEffectsTaskData(targets=targets, treatments=treatments, **kwargs)
+
+    def _validate(self) -> None:
+        if self.predictive.targets.sample_index() != self.time_series.sample_index():
+            raise ValueError(EXCEPTION_MESSAGES.sample_index_mismatch.targets)
+        if self.predictive.treatments.sample_index() != self.time_series.sample_index():
+            raise ValueError(EXCEPTION_MESSAGES.sample_index_mismatch.treatments)
+        # TODO: Possible check - check that .time_series and .predictive.targets have the same time_indexes.
+        # TODO: Possible checks - some checks on .time_series and .predictive.treatments in terms of
+        # their relative position in time?
+
+
+class TemporalTreatmentEffectsDataset(Dataset):
+    predictive: pred.TemporalTreatmentEffectsTaskData
+
+    def __init__(  # pylint: disable=useless-super-delegation
+        self,
+        time_series: data_typing.DataContainer,
+        *,
+        targets: data_typing.DataContainer,
+        treatments: data_typing.DataContainer,
+        static: Optional[data_typing.DataContainer] = None,
+        **kwargs,
+    ) -> None:
+        """A `Dataset` subclass for the temporal treatment effects problem setting, see `Dataset` docs.
+
+        In this setting: `targets` are required, will be initialized as `TimeSeriesSamples`; `treatments` are required,
+        will be initialized as `TimeSeriesSamples`.
+        """
+        super().__init__(time_series=time_series, static=static, targets=targets, treatments=treatments, **kwargs)
+
+    def _init_predictive(
+        self,
+        targets: data_typing.DataContainer,
+        treatments: Optional[data_typing.DataContainer] = None,
+        **kwargs,
+    ) -> None:
+        if targets is None:
+            raise ValueError("Temporal treatment effects task requires `targets`")
+        if treatments is None:
+            raise ValueError("Temporal treatment effects task requires `treatments`")
+        self.predictive = pred.TemporalTreatmentEffectsTaskData(targets=targets, treatments=treatments, **kwargs)
+
+    def _validate(self) -> None:
+        if self.predictive.targets.sample_index() != self.time_series.sample_index():
+            raise ValueError(EXCEPTION_MESSAGES.sample_index_mismatch.targets)
+        if self.predictive.treatments.sample_index() != self.time_series.sample_index():
+            raise ValueError(EXCEPTION_MESSAGES.sample_index_mismatch.treatments)
+        # TODO: Possible check - check that .time_series and .predictive.treatments/targets have the same time_indexes.
diff --git a/src/tempor/data/validator/impl/pandera_utils.py b/src/tempor/data/pandera_utils.py
similarity index 57%
rename from src/tempor/data/validator/impl/pandera_utils.py
rename to src/tempor/data/pandera_utils.py
index f2a8d38a..0e92872e 100644
--- a/src/tempor/data/validator/impl/pandera_utils.py
+++ b/src/tempor/data/pandera_utils.py
@@ -1,12 +1,13 @@
-from typing import Any, Dict, Iterable, List, Set, Tuple
+from typing import Any, Dict, Iterable, List, Tuple
 
 import pandas as pd
 import pandera as pa
 
-import tempor.data as dat
 from tempor.log import logger
 
-_DATA_FRAME_SCHEMA_INIT_ARGUMENTS = [
+from . import data_typing
+
+_PA_DF_SCHEMA_INIT_PARAMETERS = [
     "columns",
     "checks",
     "index",
@@ -23,7 +24,7 @@
 ]
 
 
-_INDEX_INIT_ARGUMENTS = [
+_PA_INDEX_INIT_PARAMETERS = [
     "dtype",
     "checks",
     "nullable",
@@ -36,7 +37,7 @@
 ]
 
 
-_MULTI_INDEX_INIT_ARGUMENTS = [
+_PA_MULTI_INDEX_INIT_PARAMETERS = [
     "indexes",
     "coerce",
     "strict",
@@ -47,38 +48,54 @@
 ]
 
 
-def _get_args(obj, arg_names: List[str]) -> dict:
-    # Obtain the init arguments that correspond to go the current state of the object.
+def _get_pa_init_args(pa_object: Any, param_names: List[str]) -> Dict[str, Any]:
+    """A helper method for updating `pandera` objects dynamically.
+
+    Get values of items in `pa_object`'s `__dict__`, specified by `param_names`.
+    `param_names` should contain names (`str`) of the `__init__` parameters of the `pa_object`.
+
+    Algorithm:
+    - Will attempt to get by `param_names` item.
+    - If not found, will attempt to get by `param_names` item prepended with `_`.
+    - If an `arg_name` item isn't found, it is ignored.
+
+    Args:
+        pa_object (Any): `pandera` object.
+        param_names (List[str]): list of `pandera` object's `__init__` parameters.
+
+    Returns:
+        Dict[str, Any]: dictionary mapping `pa_object`'s `__init__` parameter names to their current values.
+    """
     # Try attributes with matching name:
-    args = set(arg_names)
-    items = {k: v for k, v in obj.__dict__.items() if k in args}
+    args = set(param_names)
+    items = {k: v for k, v in pa_object.__dict__.items() if k in args}
     # If any left, try attributes prepended with `_`.
     args_left = args - set(items.keys())
     _args_left = set([f"_{i}" for i in args_left])
-    additional_items = {k[1:]: v for k, v in obj.__dict__.items() if k in _args_left}
+    additional_items = {k[1:]: v for k, v in pa_object.__dict__.items() if k in _args_left}
     items.update(additional_items)
     return items
 
 
 def update_schema(schema: pa.DataFrameSchema, /, **kwargs) -> pa.DataFrameSchema:
-    items = _get_args(schema, arg_names=_DATA_FRAME_SCHEMA_INIT_ARGUMENTS)
+    items = _get_pa_init_args(schema, param_names=_PA_DF_SCHEMA_INIT_PARAMETERS)
     items.update(kwargs)
     return pa.DataFrameSchema(**items)
 
 
-def update_index(index: pa.Index, **kwargs) -> pa.Index:
-    items = _get_args(index, arg_names=_INDEX_INIT_ARGUMENTS)
+def update_index(index: pa.Index, /, **kwargs) -> pa.Index:
+    items = _get_pa_init_args(index, param_names=_PA_INDEX_INIT_PARAMETERS)
     items.update(kwargs)
     return pa.Index(**items)
 
 
-def update_multiindex(multi_index: pa.MultiIndex, **kwargs) -> pa.MultiIndex:
-    items = _get_args(multi_index, arg_names=_MULTI_INDEX_INIT_ARGUMENTS)
+def update_multiindex(multi_index: pa.MultiIndex, /, **kwargs) -> pa.MultiIndex:
+    items = _get_pa_init_args(multi_index, param_names=_PA_MULTI_INDEX_INIT_PARAMETERS)
     items.update(kwargs)
     return pa.MultiIndex(**items)
 
 
-PA_DTYPE_MAP: Dict[dat.Dtype, pa.DataType] = {
+PA_DTYPE_MAP: Dict[data_typing.Dtype, pa.DataType] = {
     bool: pa.Bool(),
     int: pa.Int(),
     float: pa.Float(),
@@ -86,31 +103,34 @@ def update_multiindex(multi_index: pa.MultiIndex, **kwargs) -> pa.MultiIndex:
     "category": pa.Category(),
     "datetime": pa.DateTime(),
 }
+"""A mapping from dtype specified as `Dtype` to a `pandera.DataType`.
+"""
 
 
-def get_pa_dtypes(dtypes: Iterable[dat.Dtype]) -> Set[pa.DataType]:
+def get_pa_dtypes(dtypes: Iterable[data_typing.Dtype]) -> List[pa.DataType]:
+    """Return a `set` of `pandera.DataType` corresponding to `dtypes`. Raises `KeyError` If not found."""
     pa_dtypes = []
     for dt in dtypes:
         if isinstance(dt, pa.DataType):
+            # If item in `dtypes` already a `pandera.Dtype`, pass it through.
             pa_dtypes.append(dt)
         else:
             try:
                 pa_dtypes.append(PA_DTYPE_MAP[dt])
             except KeyError as ex:
                 raise KeyError(f"Mapping from `{dt}` to a pandera DataType not found") from ex
-    return set(pa_dtypes)
+    return list(set(pa_dtypes))
 
 
-def check_by_series_schema(
-    series: pd.Series, series_name: str, dtypes: Set[pa.DataType], **series_schema_kwargs
-) -> bool:
-    # Will check that the series satisfies the SeriesSchema with at least one dtype from dtypes.
-    # May pass additional SeriesSchema kwargs via series_schema_kwargs.
+def check_by_series_schema(series: pd.Series, series_name: str, dtypes: List[pa.DataType], **kwargs) -> bool:
+    """Will check that `series` satisfies a `SeriesSchema` with at least one dtype from `dtypes`.
+    May pass additional `SeriesSchema` kwargs via `kwargs`.
+    """
     logger.trace(f"Doing {series_name} dtype validation.")
     validated: List[bool] = []
-    for type_ in dtypes:
+    for type_ in set(dtypes):
         try:
-            pa.SeriesSchema(type_, **series_schema_kwargs).validate(series)
+            pa.SeriesSchema(type_, **kwargs).validate(series)
             logger.trace(f"{series_name} validated? Yes: {type_}")
             validated.append(True)
             break
@@ -120,17 +140,20 @@ def check_by_series_schema(
     return any(validated)
 
 
-def add_df_wide_checks(schema: pa.DataFrameSchema, /, *, checks_list: List[pa.Check]) -> pa.DataFrameSchema:
+def add_df_checks(schema: pa.DataFrameSchema, /, *, checks_list: List[pa.Check]) -> pa.DataFrameSchema:
     schema = update_schema(schema, checks=checks_list)
     return schema
 
 
-def add_all_column_checks(
-    schema: pa.DataFrameSchema, /, *, dtype: Any, nullable: bool, checks_list: List[pa.Check]
+def add_regex_column_checks(
+    schema: pa.DataFrameSchema, /, *, regex: str = ".*", dtype: Any, nullable: bool, checks_list: List[pa.Check]
 ) -> pa.DataFrameSchema:
+    """Update `schema` with checks specified in `checks_list`, applied to all columns specified by `regex`.
+    `dtype` and `nullable` can also be specified and will apply to all columns.
+    """
     schema_out = schema.add_columns(
         {
-            ".*": pa.Column(
+            regex: pa.Column(
                 dtype=dtype,
                 nullable=nullable,
                 regex=True,
@@ -151,6 +174,12 @@ def set_up_index(
     unique: bool,
     checks_list: List[pa.Check],
 ) -> Tuple[pa.DataFrameSchema, pd.DataFrame]:
+    """Update `schema.index` (`pandera.Index`) with `name`, `nullable`, ... schema settings.
+
+    In addition, set the index name of `data` (`pandas.DataFrame`) to `name`.
+
+    Returns the schema and the dataframe.
+    """
     if schema.index is None:
         raise ValueError("Expected DataFrameSchema Index to not be None")
     index = update_index(
@@ -174,6 +203,13 @@ def set_up_2level_multiindex(
     unique: Tuple[str, ...],
     checks_list: Tuple[List[pa.Check], List[pa.Check]],
 ) -> Tuple[pa.DataFrameSchema, pd.DataFrame]:
+    """Update `schema.index` (`pandera.MultiIndex`), which is expected to have 2 levels, with `name`, `nullable`, ...
+    schema settings.
+
+    In addition, set the index name of `data` (`pandas.DataFrame`) to `name`.
+
+    Returns the schema and the dataframe.
+    """
     if schema.index is None:
         raise ValueError("Expected DataFrameSchema Index to not be None")
     if not isinstance(schema.index, pa.MultiIndex):
@@ -200,21 +236,37 @@ def set_up_2level_multiindex(
     return schema, data
 
 
-class checks:  # Functions as a "namespace" for clarity.
-    forbid_multiindex_index = pa.Check(lambda df: df.index.nlevels == 1, error="MultiIndex Index not allowed")
+class checks:
+    """Namespace containing reusable `pandera.Check`s."""
+
+    forbid_multiindex_index = pa.Check(
+        lambda df: df.index.nlevels == 1,
+        error="MultiIndex Index not allowed",
+    )
+    forbid_multiindex_columns = pa.Check(
+        lambda df: df.columns.nlevels == 1,
+        error="MultiIndex Columns not allowed",
+    )
     require_2level_multiindex_index = pa.Check(
-        lambda df: df.index.nlevels == 2, error="Index must be a MultiIndex with 2 levels"
+        lambda df: df.index.nlevels == 2,
+        error="Index must be a MultiIndex with 2 levels",
     )
     require_2level_multiindex_one_to_one = pa.Check(
         lambda df: (df.groupby(level=0).size() == 1).all(),
         error="MultiIndex Index must one-to-one correspondence for between the two levels",
     )
-    forbid_multiindex_columns = pa.Check(lambda df: df.columns.nlevels == 1, error="MultiIndex Columns not allowed")
+    require_element_len_2 = pa.Check(
+        lambda x: len(x) == 2,
+        element_wise=True,
+        error="Each item must contain a sequence of length 2",
+    )
+
+    class configurable:
+        """Namespace containing functions to get configurable `pandera.Check`s."""
 
-    class dynamic:
         @staticmethod
-        def values_are_one_of_dtypes(dtypes: Set[dat.Dtype]) -> pa.Check:
-            series_name = "(Column) Values"
+        def values_satisfy_dtypes(dtypes: List[data_typing.Dtype]) -> pa.Check:
+            series_name = "Values"
             error = str(f"DataFrame {series_name} dtype validation failed, must be one of: {dtypes}")
             return pa.Check(
                 lambda col: check_by_series_schema(
@@ -228,7 +280,7 @@ def values_are_one_of_dtypes(dtypes: Set[dat.Dtype]) -> pa.Check:
             )
 
         @staticmethod
-        def index_is_one_of_dtypes(dtypes: Set[dat.Dtype]) -> pa.Check:
+        def index_satisfies_dtypes(dtypes: List[data_typing.Dtype]) -> pa.Check:
             series_name = "Index"
             error = str(f"DataFrame {series_name} dtype validation failed, must be one of: {dtypes}")
             return pa.Check(
@@ -236,14 +288,14 @@ def index_is_one_of_dtypes(dtypes: Set[dat.Dtype]) -> pa.Check:
                     pd.Series(idx),
                     series_name=series_name,
                     dtypes=get_pa_dtypes(dtypes),
-                    nullable=True,
+                    nullable=False,
                     coerce=False,
                 ),
                 error=error,
             )
 
         @staticmethod
-        def column_index_is_one_of_dtypes(dtypes: Set[dat.Dtype], *, nullable: bool) -> pa.Check:
+        def column_index_satisfies_dtypes(dtypes: List[data_typing.Dtype], *, nullable: bool) -> pa.Check:
             series_name = "Column Index"
             error = str(f"DataFrame {series_name} dtype validation failed, must be one of: {dtypes}")
             return pa.Check(
diff --git a/src/tempor/data/predictive.py b/src/tempor/data/predictive.py
new file mode 100644
index 00000000..d184b35d
--- /dev/null
+++ b/src/tempor/data/predictive.py
@@ -0,0 +1,128 @@
+import abc
+from typing import Any, Optional
+
+import rich.pretty
+
+from tempor.core.utils import RichReprStrPassthrough
+
+from . import data_typing, samples
+
+# TODO: Unit test.
+
+
+class PredictiveTaskData(abc.ABC):
+    targets: samples.DataSamples
+    treatments: Optional[samples.DataSamples]
+
+    @property
+    @abc.abstractmethod
+    def predictive_task(self) -> data_typing.PredictiveTask:  # pragma: no cover
+        ...
+
+    @abc.abstractmethod
+    def __init__(
+        self,
+        targets: Any,
+        treatments: Optional[Any],
+        **kwargs,
+    ) -> None:  # pragma: no cover
+        ...
+
+    def __rich_repr__(self):
+        yield "targets", RichReprStrPassthrough(self.targets.short_repr())
+        if self.treatments is not None:
+            yield "treatments", RichReprStrPassthrough(self.treatments.short_repr())
+
+    def __repr__(self) -> str:
+        return rich.pretty.pretty_repr(self)
+
+
+# Predictive task data classes corresponding to different tasks follow. More can be added to handle new tasks.
+
+# --- Prediction tasks: ---
+
+
+class OneOffPredictionTaskData(PredictiveTaskData):
+    # One-off prediction (e.g., one-off classification with a target like patient death).
+
+    targets: samples.StaticSamples
+    treatments: None
+
+    @property
+    def predictive_task(self) -> data_typing.PredictiveTask:
+        return data_typing.PredictiveTask.ONE_OFF_PREDICTION
+
+    def __init__(self, targets: data_typing.DataContainer, **kwargs) -> None:
+        self.targets = samples.StaticSamples(targets)
+        self.treatments = None
+        super().__init__(targets=targets, treatments=None)
+
+
+class TemporalPredictionTaskData(PredictiveTaskData):
+    # Temporal prediction (e.g., predicting a patient's temperature real valued time series).
+
+    targets: samples.TimeSeriesSamples
+    treatments: None
+
+    @property
+    def predictive_task(self) -> data_typing.PredictiveTask:
+        return data_typing.PredictiveTask.TEMPORAL_PREDICTION
+
+    def __init__(self, targets: data_typing.DataContainer, **kwargs) -> None:
+        self.targets = samples.TimeSeriesSamples(targets)
+        self.treatments = None
+        super().__init__(targets=targets, treatments=None)
+
+
+# --- Time-to-event tasks: ---
+
+
+class TimeToEventAnalysisTaskData(PredictiveTaskData):
+    # Time-to-event (survival) analysis (e.g. Dynamic DeepHit).
+
+    targets: samples.EventSamples
+    treatments: None
+
+    @property
+    def predictive_task(self) -> data_typing.PredictiveTask:
+        return data_typing.PredictiveTask.TIME_TO_EVENT_ANALYSIS
+
+    def __init__(self, targets: data_typing.DataContainer, **kwargs) -> None:
+        self.targets = samples.EventSamples(targets)
+        self.treatments = None
+        super().__init__(targets=targets, treatments=None)
+
+
+# --- Treatment Effects tasks: ---
+
+
+class OneOffTreatmentEffectsTaskData(PredictiveTaskData):
+    # Treatment effects with time series outcomes but one-off treatment event(s) (e.g. SyncTwin)
+
+    targets: samples.TimeSeriesSamples
+    treatments: samples.EventSamples
+
+    @property
+    def predictive_task(self) -> data_typing.PredictiveTask:
+        return data_typing.PredictiveTask.TEMPORAL_TREATMENT_EFFECTS
+
+    def __init__(self, targets: data_typing.DataContainer, treatments: data_typing.DataContainer, **kwargs) -> None:
+        self.targets = samples.TimeSeriesSamples(targets)
+        self.treatments = samples.EventSamples(treatments)
+        super().__init__(targets=targets, treatments=treatments)
+
+
+class TemporalTreatmentEffectsTaskData(PredictiveTaskData):
+    # Temporal treatment effects (i.e. outcomes are time series and treatments are also time series, e.g. RMSN, CRN).
+
+    targets: samples.TimeSeriesSamples
+    treatments: samples.TimeSeriesSamples
+
+    @property
+    def predictive_task(self) -> data_typing.PredictiveTask:
+        return data_typing.PredictiveTask.TEMPORAL_TREATMENT_EFFECTS
+
+    def __init__(self, targets: data_typing.DataContainer, treatments: data_typing.DataContainer, **kwargs) -> None:
+        self.targets = samples.TimeSeriesSamples(targets)
+        self.treatments = samples.TimeSeriesSamples(treatments)
+        super().__init__(targets=targets, treatments=treatments)
diff --git a/src/tempor/data/requirements.py b/src/tempor/data/requirements.py
deleted file mode 100644
index faea027f..00000000
--- a/src/tempor/data/requirements.py
+++ /dev/null
@@ -1,80 +0,0 @@
-from typing import ClassVar, Dict, List, Set, Type
-
-import pydantic
-
-import tempor.data as dat
-
-DATA_REQUIREMENTS: Dict[dat.DataCategory, Set[Type["DataRequirement"]]] = {
-    dat.DataCategory.STATIC: set(),
-    dat.DataCategory.TIME_SERIES: set(),
-    dat.DataCategory.EVENT: set(),
-}
-
-
-class DataRequirementMeta(pydantic.main.ModelMetaclass, type):
-    _base_class_name: str = "DataRequirement"
-    _categories_attr_name: str = "categories"
-
-    def __new__(mcls, name, bases, namespace, /, **kwargs):  # pyright: ignore
-        data_req_cls: Type["DataRequirement"] = super().__new__(mcls, name, bases, namespace, **kwargs)  # type: ignore
-
-        name = data_req_cls.__name__  # type: ignore
-        mro_names = [cls.__name__ for cls in data_req_cls.mro()]  # type: ignore
-        if name != mcls._base_class_name and mcls._base_class_name not in mro_names:
-            raise TypeError(
-                f"A class with metaclass {mcls.__name__} should either be the base class named {mcls._base_class_name} "
-                f"or inherit from the base class named {mcls._base_class_name}"
-            )
-        if not hasattr(data_req_cls, mcls._categories_attr_name):
-            raise TypeError(
-                f"A class with metaclass {mcls.__name__} should have an attribute "
-                f"named {mcls._categories_attr_name}"
-            )
-
-        if not data_req_cls.__name__ == mcls._base_class_name and not data_req_cls.categories:  # type: ignore
-            raise TypeError(
-                f"{data_req_cls.__name__} must define at least one {mcls._categories_attr_name}"  # pyright: ignore
-            )
-
-        # Register each data requirement:
-        for cat in data_req_cls.categories:
-            DATA_REQUIREMENTS[cat].add(data_req_cls)
-
-        return data_req_cls
-
-
-class DataRequirement(pydantic.BaseModel, metaclass=DataRequirementMeta):  # pylint: disable=no-member
-    categories: ClassVar[Set[dat.DataCategory]] = set()
-
-    class Config:
-        arbitrary_types_allowed = True
-
-
-class ValueDTypes(DataRequirement):
-    value_dtypes: List[dat.Dtype]
-
-    @pydantic.validator("value_dtypes")
-    def check_supported_value_dtypes(cls, v):  # pylint: disable=no-self-argument
-        unsupported = set(v) - set(dat.DATA_SETTINGS.value_dtypes)
-        if unsupported:
-            raise TypeError(
-                f"Value dtypes must only include supported dtypes {list(dat.DATA_SETTINGS.value_dtypes)} "
-                f"but {list(unsupported)} found"
-            )
-        return v
-
-    categories: ClassVar[Set[dat.DataCategory]] = {
-        dat.DataCategory.STATIC,
-        dat.DataCategory.TIME_SERIES,
-        dat.DataCategory.EVENT,
-    }
-
-
-class AllowMissing(DataRequirement):
-    allow_missing: bool
-
-    categories: ClassVar[Set[dat.DataCategory]] = {
-        dat.DataCategory.STATIC,
-        dat.DataCategory.TIME_SERIES,
-        dat.DataCategory.EVENT,
-    }
diff --git a/src/tempor/data/samples.py b/src/tempor/data/samples.py
new file mode 100644
index 00000000..820b5ae9
--- /dev/null
+++ b/src/tempor/data/samples.py
@@ -0,0 +1,651 @@
+"""Data handling for different data samples modalities supported by TemporAI.
+"""
+
+# pylint: disable=useless-super-delegation, unnecessary-ellipsis
+
+import abc
+from typing import TYPE_CHECKING, Any, List, Optional
+
+import numpy as np
+import pandas as pd
+import pandera as pa
+import pydantic
+
+import tempor.exc
+from tempor.log import log_helpers, logger
+
+from . import data_typing, pandera_utils, utils
+from .settings import DATA_SETTINGS
+
+
+class DataSamples(abc.ABC):
+    _data: Any
+
+    @property
+    @abc.abstractmethod
+    def modality(self) -> data_typing.DataModality:  # pragma: no cover
+        ...
+
+    def __init__(
+        self,
+        data: data_typing.DataContainer,  # pylint: disable=unused-argument
+        **kwargs,  # pylint: disable=unused-argument
+    ) -> None:  # pragma: no cover
+        self.validate()
+
+    def __repr__(self) -> str:
+        return f"{self.__class__.__name__} with data:\n{self.dataframe()}"
+
+    def validate(self) -> None:
+        with log_helpers.exc_to_log():
+            try:
+                self._validate()
+            except (
+                pa.errors.SchemaError,
+                pa.errors.SchemaErrors,
+                ValueError,
+                TypeError,
+            ) as ex:
+                raise tempor.exc.DataValidationException(
+                    "Data validation failed, see traceback for more details"
+                ) from ex
+
+    @abc.abstractmethod
+    def _validate(self) -> None:  # pragma: no cover
+        """Validate integrity of the data samples. Raise any of `ValueError`, `TypeError`,
+        `pandera.errors.SchemaError[s]` (or exceptions derived from these) to indicate validation failure.
+        """
+        ...
+
+    @staticmethod
+    @abc.abstractmethod
+    def from_numpy(
+        array: np.ndarray,
+        *,
+        sample_index: Optional[data_typing.SampleIndex] = None,
+        feature_index: Optional[data_typing.FeatureIndex] = None,
+        **kwargs,
+    ) -> "DataSamples":  # pragma: no cover
+        """Create `DataSamples` from `numpy.ndarray`.
+
+        Args:
+            array (np.ndarray): The array that represents the data.
+            sample_index (List[<sample element>], optional): List with sample (row) index for each sample.
+            Optional, if `None`, will be of form `[0, 1, ...]`. Defaults to `None`.
+            feature_index (List[<feature element>], optional): List with feature (column) index for each feature.
+            Optional, if `None`, will be of form `["feat_0", "feat_1", ...]`. Defaults to `None`.
+
+        Returns:
+            DataSamples: `DataSamples` object from `array`.
+        """
+        ...
+
+    @staticmethod
+    @abc.abstractmethod
+    def from_dataframe(dataframe: pd.DataFrame, **kwargs) -> "DataSamples":  # pragma: no cover
+        """
+        Create `DataSamples` from `pandas.DataFrame`.
+        """
+        ...
+
+    @abc.abstractmethod
+    def numpy(self, **kwargs) -> np.ndarray:  # pragma: no cover
+        """Return `numpy.ndarray` representation of the data."""
+        ...
+
+    @abc.abstractmethod
+    def dataframe(self, **kwargs) -> pd.DataFrame:  # pragma: no cover
+        """Return `pandas.DataFrame` representation of the data."""
+        ...
+
+    @property
+    @abc.abstractmethod
+    def num_samples(self) -> int:  # pragma: no cover
+        """Return number of samples."""
+        ...
+
+    @abc.abstractmethod
+    def sample_index(self) -> data_typing.SampleIndex:  # pragma: no cover
+        """Return a list representing sample indexes."""
+        ...
+
+    def __len__(self) -> int:
+        return self.num_samples
+
+    @property
+    @abc.abstractmethod
+    def num_features(self) -> int:  # pragma: no cover
+        """Return number of features."""
+        ...
+
+    @abc.abstractmethod
+    def short_repr(self) -> str:  # pragma: no cover
+        ...
+
+
+def _array_default_sample_index(array: np.ndarray) -> List[int]:
+    n_samples, *_ = array.shape
+    return list(range(0, n_samples))
+
+
+def _array_default_feature_index(array: np.ndarray) -> List[str]:
+    *_, n_features = array.shape
+    return [f"feat_{x}" for x in range(0, n_features)]
+
+
+def _array_default_time_indexes(array: np.ndarray, padding_indicator: Any) -> List[List[int]]:
+    lengths = utils.get_seq_lengths_timeseries_array3d(array, padding_indicator)
+    return [list(range(x)) for x in lengths]
+
+
+class StaticSamples(DataSamples):
+    _data: pd.DataFrame
+    _schema: pa.DataFrameSchema
+
+    @pydantic.validate_arguments(config={"arbitrary_types_allowed": True, "smart_union": True})
+    def __init__(
+        self,
+        data: data_typing.DataContainer,
+        *,
+        sample_index: Optional[data_typing.SampleIndex] = None,
+        feature_index: Optional[data_typing.FeatureIndex] = None,
+        **kwargs,
+    ) -> None:
+        """Create a `StaticSamples` object from the `data`.
+
+        Args:
+            data (numpy.ndarray | pandas.DataFrame): A container with the data.
+            sample_index (List[<sample element>], optional): Used only if `data` is a `numpy.ndarray`. List with sample
+            (row) index for each sample. Optional, if `None`, will be of form `[0, 1, ...]`. Defaults to `None`.
+            feature_index (List[<feature element>], optional): Used only if `data` is a `numpy.ndarray`.  List with
+            feature (column) index for each feature. Optional, if `None`, will be of form `["feat_0", "feat_1", ...]`.
+            Defaults to `None`.
+        """
+        if isinstance(data, pd.DataFrame):
+            self._data = data
+        elif isinstance(data, np.ndarray):
+            self._data = self._array_to_df(data, sample_index=sample_index, feature_index=feature_index, **kwargs)
+        else:  # pragma: no cover  # Prevented by pydantic check.
+            raise ValueError(f"Data object {type(data)} not supported")
+        super().__init__(data, **kwargs)
+
+    @property
+    def modality(self) -> data_typing.DataModality:
+        return data_typing.DataModality.STATIC
+
+    def _validate(self) -> None:
+        schema = pa.infer_schema(self._data)
+        if TYPE_CHECKING:  # pragma: no cover
+            assert isinstance(schema, pa.DataFrameSchema)
+        logger.debug(f"Inferred schema:\n{schema}")
+
+        # DataFrame-level validation:
+        schema = pandera_utils.add_df_checks(
+            schema,
+            checks_list=[
+                pandera_utils.checks.forbid_multiindex_index,
+                pandera_utils.checks.forbid_multiindex_columns,
+                pandera_utils.checks.configurable.column_index_satisfies_dtypes(
+                    DATA_SETTINGS.feature_index_dtypes, nullable=DATA_SETTINGS.feature_index_nullable
+                ),
+            ],
+        )
+        self._data = schema.validate(self._data)
+
+        # Values validation:
+        schema = pandera_utils.add_regex_column_checks(
+            schema,
+            regex=".*",
+            dtype=None,
+            nullable=DATA_SETTINGS.static_values_nullable,
+            checks_list=[pandera_utils.checks.configurable.values_satisfy_dtypes(DATA_SETTINGS.static_value_dtypes)],
+        )
+        self._data = schema.validate(self._data)
+
+        # Index validation:
+        schema, data = pandera_utils.set_up_index(
+            schema,
+            self._data,
+            name=DATA_SETTINGS.sample_index_name,
+            nullable=DATA_SETTINGS.sample_index_nullable,
+            unique=DATA_SETTINGS.sample_index_unique,
+            checks_list=[pandera_utils.checks.configurable.index_satisfies_dtypes(DATA_SETTINGS.sample_index_dtypes)],
+        )
+        self._data = schema.validate(data)
+
+        logger.debug(f"Final schema:\n{schema}")
+        self._schema = schema
+
+    @staticmethod
+    def from_dataframe(dataframe: pd.DataFrame, **kwargs) -> "StaticSamples":
+        return StaticSamples(dataframe, **kwargs)
+
+    @staticmethod
+    def from_numpy(
+        array: np.ndarray,
+        *,
+        sample_index: Optional[data_typing.SampleIndex] = None,
+        feature_index: Optional[data_typing.FeatureIndex] = None,
+        **kwargs,
+    ) -> "StaticSamples":
+        return StaticSamples(array, sample_index=sample_index, feature_index=feature_index, **kwargs)
+
+    @staticmethod
+    def _array_to_df(
+        array: np.ndarray,
+        *,
+        sample_index: Optional[data_typing.SampleIndex] = None,
+        feature_index: Optional[data_typing.FeatureIndex] = None,
+        **kwargs,
+    ) -> pd.DataFrame:
+        if sample_index is None:
+            sample_index = _array_default_sample_index(array)  # pyright: ignore
+        if feature_index is None:
+            feature_index = _array_default_feature_index(array)
+        return pd.DataFrame(data=array, index=sample_index, columns=feature_index, **kwargs)
+
+    def numpy(self, **kwargs) -> np.ndarray:
+        return self._data.to_numpy()
+
+    def dataframe(self, **kwargs) -> pd.DataFrame:
+        return self._data
+
+    def sample_index(self) -> data_typing.SampleIndex:
+        return list(self._data.index)  # pyright: ignore
+
+    @property
+    def num_samples(self) -> int:
+        return self._data.shape[0]
+
+    @property
+    def num_features(self) -> int:
+        return self._data.shape[1]
+
+    def short_repr(self) -> str:
+        return f"{self.__class__.__name__}([{self.num_samples}, {self.num_features}])"
+
+
+class TimeSeriesSamples(DataSamples):
+    _data: pd.DataFrame
+    _schema: pa.DataFrameSchema
+
+    @property
+    def modality(self) -> data_typing.DataModality:
+        return data_typing.DataModality.TIME_SERIES
+
+    @pydantic.validate_arguments(config={"arbitrary_types_allowed": True, "smart_union": True})
+    def __init__(
+        self,
+        data: data_typing.DataContainer,
+        *,
+        padding_indicator: Any = None,
+        sample_index: Optional[data_typing.SampleIndex] = None,
+        time_indexes: Optional[data_typing.TimeIndexList] = None,
+        feature_index: Optional[data_typing.FeatureIndex] = None,
+        **kwargs,
+    ) -> None:
+        """Create a `TimeSeriesSamples` object from the `data`.
+
+        If `data` is a `pandas.DataFrame`, this should be a 2-level multiindex (sample, timestep) dataframe.
+
+        If `data` is a `numpy.ndarray`, this should be a 3D array, with dimensions `(sample, timestep, feature)`.
+        Optionally, padding values of `padding_indicator` can be set inside the array to pad out the length of arrays
+        of different samples in case they differ. Padding needs to go at the end of the timesteps (dim 1). Padding must
+        be the same across the feature dimension (dim 2) for each sample.
+
+        Args:
+            data (numpy.ndarray | pandas.DataFrame): A container with the data.
+            padding_indicator (Any, optional): Padding indicator used in `data` to indicate padding. Defaults to None.
+            sample_index (List[<sample element>], optional): Used only if `data` is a `numpy.ndarray`. List with sample
+            (row) index for each sample. Optional, if `None`, will be of form `[0, 1, ...]`. Defaults to `None`.
+            time_indexes (List[List[<timestep element>]], optional): Used only if `data` is a `numpy.ndarray`. List of
+            lists containing timesteps for each sample (outer list should be the same length as dim 0 of `data`,
+            inner list should contain as many elements as each sample has timesteps). Optional, if `None`, will be of
+            form `[[0, 1, ...], [0, 1, ...], ...]` Defaults to None.
+            feature_index (List[<feature element>], optional): Used only if `data` is a `numpy.ndarray`.  List with
+            feature (column) index for each feature. Optional, if `None`, will be of form `["feat_0", "feat_1", ...]`.
+        """
+        if isinstance(data, pd.DataFrame):
+            self._data = data
+        elif isinstance(data, np.ndarray):
+            self._data = self._array_to_df(
+                data,
+                padding_indicator=padding_indicator,
+                sample_index=sample_index,
+                time_indexes=time_indexes,
+                feature_index=feature_index,
+                **kwargs,
+            )
+        else:  # pragma: no cover  # Prevented by pydantic check.
+            raise ValueError(f"Data object {type(data)} not supported")
+        super().__init__(data, **kwargs)
+
+    def _validate(self) -> None:
+        schema = pa.infer_schema(self._data)
+        if TYPE_CHECKING:  # pragma: no cover
+            assert isinstance(schema, pa.DataFrameSchema)
+        logger.debug(f"Inferred schema:\n{schema}")
+
+        # DataFrame-level validation:
+        schema = pandera_utils.add_df_checks(
+            schema,
+            checks_list=[
+                pandera_utils.checks.forbid_multiindex_columns,
+                pandera_utils.checks.require_2level_multiindex_index,
+                pandera_utils.checks.configurable.column_index_satisfies_dtypes(
+                    DATA_SETTINGS.feature_index_dtypes, nullable=DATA_SETTINGS.feature_index_nullable
+                ),
+            ],
+        )
+        self._data = schema.validate(self._data)
+
+        # Values validation:
+        schema = pandera_utils.add_regex_column_checks(
+            schema,
+            regex=".*",
+            dtype=None,
+            nullable=DATA_SETTINGS.time_series_values_nullable,
+            checks_list=[
+                pandera_utils.checks.configurable.values_satisfy_dtypes(DATA_SETTINGS.time_series_value_dtypes)
+            ],
+        )
+        self._data = schema.validate(self._data)
+
+        # Index validation:
+        if not (DATA_SETTINGS.sample_index_unique and DATA_SETTINGS.sample_timestep_index_unique):
+            raise NotImplementedError("Only supported case: unique sample and unique timestep indexes")
+        multiindex_unique_def = (DATA_SETTINGS.sample_index_name, DATA_SETTINGS.time_index_name)
+        schema, data = pandera_utils.set_up_2level_multiindex(
+            schema,
+            self._data,
+            names=(DATA_SETTINGS.sample_index_name, DATA_SETTINGS.time_index_name),
+            nullable=(DATA_SETTINGS.sample_index_nullable, DATA_SETTINGS.time_index_nullable),
+            unique=multiindex_unique_def,
+            checks_list=(
+                [pandera_utils.checks.configurable.index_satisfies_dtypes(DATA_SETTINGS.sample_index_dtypes)],
+                [pandera_utils.checks.configurable.index_satisfies_dtypes(DATA_SETTINGS.time_index_dtypes)],
+            ),
+        )
+        self._data = schema.validate(data)
+
+        logger.debug(f"Final schema:\n{schema}")
+        self._schema = schema
+
+        # TODO:
+        # Possible additional validation checks:
+        # - Ensure time index sorted ascending within each sample.
+        # - Time index float / int expected non-negative values.
+
+    @staticmethod
+    def from_dataframe(dataframe: pd.DataFrame, **kwargs) -> "TimeSeriesSamples":
+        return TimeSeriesSamples(dataframe, **kwargs)
+
+    @staticmethod
+    def from_numpy(
+        array: np.ndarray,
+        *,
+        padding_indicator: Any = None,
+        sample_index: Optional[data_typing.SampleIndex] = None,
+        time_indexes: Optional[data_typing.TimeIndexList] = None,
+        feature_index: Optional[data_typing.FeatureIndex] = None,
+        **kwargs,
+    ) -> "TimeSeriesSamples":
+        return TimeSeriesSamples(
+            array,
+            padding_indicator=padding_indicator,
+            sample_index=sample_index,
+            time_indexes=time_indexes,
+            feature_index=feature_index,
+            **kwargs,
+        )
+
+    @staticmethod
+    def _array_to_df(
+        array: np.ndarray,
+        *,
+        padding_indicator: Any,
+        sample_index: Optional[data_typing.SampleIndex] = None,
+        time_indexes: Optional[data_typing.TimeIndexList] = None,
+        feature_index: Optional[data_typing.FeatureIndex] = None,
+        **kwargs,  # pylint: disable=unused-argument
+    ) -> pd.DataFrame:
+        if sample_index is None:
+            sample_index = _array_default_sample_index(array)  # pyright: ignore
+        if feature_index is None:
+            feature_index = _array_default_feature_index(array)
+        if time_indexes is None:
+            time_indexes = _array_default_time_indexes(array, padding_indicator)  # type: ignore
+        if TYPE_CHECKING:  # pragma: no cover
+            assert sample_index is not None and feature_index is not None and time_indexes is not None
+        return utils.array3d_to_multiindex_timeseries_dataframe(
+            array,
+            sample_index=sample_index,
+            time_indexes=time_indexes,
+            feature_index=feature_index,
+            padding_indicator=padding_indicator,
+        )
+
+    def numpy(self, *, padding_indicator: Any = DATA_SETTINGS.default_padding_indicator, **kwargs) -> np.ndarray:
+        return utils.multiindex_timeseries_dataframe_to_array3d(
+            df=self._data, padding_indicator=padding_indicator, max_timesteps=None
+        )
+
+    def dataframe(self, **kwargs) -> pd.DataFrame:
+        return self._data
+
+    def sample_index(self) -> data_typing.SampleIndex:
+        return list(self._data.index.levels[0])  # pyright: ignore
+
+    def time_indexes(self) -> data_typing.TimeIndexList:
+        """Get a list containing time indexes for each sample. Each time index is represented as a list of time step
+        elements.
+
+        Returns:
+            List[List[<timestep element>]]: A list containing time indexes for each sample.
+        """
+        return list(self.time_indexes_as_dict().values())
+
+    def time_indexes_as_dict(self) -> data_typing.SampleToTimeIndexDict:
+        """Get a dictionary mapping each sample index to its time index. Time index is represented as a list of time
+        step elements.
+
+        Returns:
+            Dict[<sample element>, List[<timestep element>]]: A list containing time indexes for each sample.
+        """
+        multiindex = self._data.index
+        if TYPE_CHECKING:  # pragma: no cover
+            assert isinstance(multiindex, pd.MultiIndex)
+        sample_index = list(self._data.index.levels[0])  # pyright: ignore
+        d = dict()
+        for s in sample_index:
+            time_index_locs = multiindex.get_locs(s)
+            d[s] = list(multiindex.get_level_values(1)[time_index_locs])
+        return d
+
+    # TODO: time indexes sensibly converted to floats would be useful.
+
+    @property
+    def num_samples(self) -> int:
+        sample_ids = self._data.index.levels[0]  # pyright: ignore
+        return len(sample_ids)
+
+    @property
+    def num_features(self) -> int:
+        return self._data.shape[1]
+
+    def short_repr(self) -> str:
+        return f"{self.__class__.__name__}([{self.num_samples}, *, {self.num_features}])"
+
+
+class EventSamples(DataSamples):
+    _data: pd.DataFrame
+    _schema: pa.DataFrameSchema
+    _schema_split: pa.DataFrameSchema
+
+    @property
+    def modality(self) -> data_typing.DataModality:
+        return data_typing.DataModality.EVENT
+
+    @pydantic.validate_arguments(config={"arbitrary_types_allowed": True, "smart_union": True})
+    def __init__(
+        self,
+        data: data_typing.DataContainer,
+        *,
+        sample_index: Optional[data_typing.SampleIndex] = None,
+        feature_index: Optional[data_typing.FeatureIndex] = None,
+        **kwargs,
+    ) -> None:
+        """Create an `EventSamples` object from the `data`.
+
+        Args:
+            data (numpy.ndarray | pandas.DataFrame): A container with the data.
+            sample_index (List[<sample element>], optional): Used only if `data` is a `numpy.ndarray`. List with sample
+            (row) index for each sample. Optional, if `None`, will be of form `[0, 1, ...]`. Defaults to `None`.
+            feature_index (List[<feature element>], optional): Used only if `data` is a `numpy.ndarray`.  List with
+            feature (column) index for each feature. Optional, if `None`, will be of form `["feat_0", "feat_1", ...]`.
+            Defaults to `None`.
+        """
+        if isinstance(data, pd.DataFrame):
+            self._data = data
+        elif isinstance(data, np.ndarray):
+            self._data = self._array_to_df(data, sample_index=sample_index, feature_index=feature_index, **kwargs)
+        else:  # pragma: no cover  # Prevented by pydantic check.
+            raise ValueError(f"Data object {type(data)} not supported")
+        super().__init__(data, **kwargs)
+
+    def _validate(self) -> None:
+        schema = pa.infer_schema(self._data)
+        if TYPE_CHECKING:  # pragma: no cover
+            assert isinstance(schema, pa.DataFrameSchema)
+        logger.debug(f"Inferred schema:\n{schema}")
+
+        # DataFrame-level validation:
+        schema = pandera_utils.add_df_checks(
+            schema,
+            checks_list=[
+                pandera_utils.checks.forbid_multiindex_index,
+                pandera_utils.checks.forbid_multiindex_columns,
+                pandera_utils.checks.configurable.column_index_satisfies_dtypes(
+                    DATA_SETTINGS.feature_index_dtypes, nullable=DATA_SETTINGS.feature_index_nullable
+                ),
+            ],
+        )
+        self._data = schema.validate(self._data)
+
+        # Values validation:
+        schema = pandera_utils.add_regex_column_checks(
+            schema,
+            regex=".*",
+            dtype=None,
+            nullable=DATA_SETTINGS.event_values_nullable,
+            checks_list=[pandera_utils.checks.require_element_len_2],
+        )
+        self._data = schema.validate(self._data)
+        # Validate event time and value components:
+        suffix = "_time"
+        data_split = self.split(time_feature_suffix=suffix)
+        schema_split = pa.infer_schema(data_split)
+        schema_split = pandera_utils.add_regex_column_checks(
+            schema_split,
+            regex=f".*{suffix}$",  # Event time columns, end in "_time".
+            dtype=None,
+            nullable=DATA_SETTINGS.time_index_nullable,
+            checks_list=[pandera_utils.checks.configurable.values_satisfy_dtypes(DATA_SETTINGS.time_index_dtypes)],
+        )
+        schema_split = pandera_utils.add_regex_column_checks(
+            schema_split,
+            regex=f"^((?!{suffix}$).)*$",  # Event value columns, do not end in "_time".
+            dtype=None,
+            nullable=DATA_SETTINGS.event_values_nullable,
+            checks_list=[pandera_utils.checks.configurable.values_satisfy_dtypes(DATA_SETTINGS.event_value_dtypes)],
+        )
+        logger.debug(f"Time split-off schema (checks event time and values separately):\n{schema_split}")
+        schema_split.validate(data_split)
+        self._schema_split = schema_split
+
+        # Index validation:
+        schema, data = pandera_utils.set_up_index(
+            schema,
+            self._data,
+            name=DATA_SETTINGS.sample_index_name,
+            nullable=DATA_SETTINGS.sample_index_nullable,
+            unique=DATA_SETTINGS.sample_index_unique,
+            checks_list=[pandera_utils.checks.configurable.index_satisfies_dtypes(DATA_SETTINGS.sample_index_dtypes)],
+        )
+        self._data = schema.validate(data)
+
+        logger.debug(f"Final schema:\n{schema}")
+        self._schema = schema
+
+    @staticmethod
+    def from_dataframe(dataframe: pd.DataFrame, **kwargs) -> "EventSamples":
+        return EventSamples(dataframe, **kwargs)
+
+    @staticmethod
+    def from_numpy(
+        array: np.ndarray,
+        *,
+        sample_index: Optional[data_typing.SampleIndex] = None,
+        feature_index: Optional[data_typing.FeatureIndex] = None,
+        **kwargs,
+    ) -> "EventSamples":
+        return EventSamples(array, sample_index=sample_index, feature_index=feature_index, **kwargs)
+
+    @staticmethod
+    def _array_to_df(
+        array: np.ndarray,
+        *,
+        sample_index: Optional[data_typing.SampleIndex] = None,
+        feature_index: Optional[data_typing.FeatureIndex] = None,
+        **kwargs,
+    ) -> pd.DataFrame:
+        if sample_index is None:
+            sample_index = _array_default_sample_index(array)  # pyright: ignore
+        if feature_index is None:
+            feature_index = _array_default_feature_index(array)
+        return pd.DataFrame(data=array, index=sample_index, columns=feature_index, **kwargs)
+
+    def numpy(self, **kwargs) -> np.ndarray:
+        # TODO: May want at option to return a scikit-survive -style array.
+        return self._data.to_numpy()
+
+    def dataframe(self, **kwargs) -> pd.DataFrame:
+        return self._data
+
+    def sample_index(self) -> data_typing.SampleIndex:
+        return list(self._data.index)  # pyright: ignore
+
+    @property
+    def num_samples(self) -> int:
+        return self._data.shape[0]
+
+    @property
+    def num_features(self) -> int:
+        return self._data.shape[1]
+
+    @pydantic.validate_arguments(config={"arbitrary_types_allowed": True})
+    def split(self, time_feature_suffix: str = "_time") -> pd.DataFrame:
+        """Return a `pandas.DataFrame` where the time component of each event feature has been split off to its own
+        column. The new columns that contain the times will be named `"<original column name><time_feature_suffix>"`
+        and will be inserted before each corresponding `<original column name>` column. The `<original column name>`
+        columns will contain only the event value.
+
+        Args:
+            time_feature_suffix (str, optional): A column name suffix string to identify the time columns that will be
+            split off. Defaults to `"_time"`.
+
+        Returns:
+            pd.DataFrame: The output dataframe.
+        """
+        df = self._data.copy()
+        features = list(df.columns)
+        if any(time_feature_suffix in str(c) for c in features):
+            raise ValueError(f"Column names must not contain '{time_feature_suffix}'")
+        for f_idx, f in enumerate(features):
+            df.insert(f_idx * 2, f"{f}{time_feature_suffix}", df[f].apply(lambda x: x[0]))
+        for f in features:
+            df[f] = df[f].apply(lambda x: x[1])
+        return df
+
+    def short_repr(self) -> str:
+        return f"{self.__class__.__name__}([{self.num_samples}, {self.num_features}])"
diff --git a/src/tempor/data/samples/__init__.py b/src/tempor/data/samples/__init__.py
deleted file mode 100644
index 65556525..00000000
--- a/src/tempor/data/samples/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-from ._samples import EventSamples, StaticSamples, TimeSeriesSamples
-
-__all__ = [
-    "StaticSamples",
-    "TimeSeriesSamples",
-    "EventSamples",
-]
diff --git a/src/tempor/data/samples/_samples.py b/src/tempor/data/samples/_samples.py
deleted file mode 100644
index 9f07d5af..00000000
--- a/src/tempor/data/samples/_samples.py
+++ /dev/null
@@ -1,110 +0,0 @@
-from typing import Any, Dict, Optional
-
-import pandas as pd
-
-import tempor.data as dat
-
-from . import impl, interface
-
-
-class Samples(interface.SamplesInterface, dat.SupportsContainer):
-    # For any reused functionality in child {Static,TimeSeries,Event}Samples.
-
-    def __init__(self, data: dat.DataContainer, container_flavor: Optional[dat.ContainerFlavor]) -> None:
-        self._data = data
-        if container_flavor is not None:
-            self._container_flavor = container_flavor
-        else:
-            self._container_flavor = dat.DEFAULT_CONTAINER_FLAVOR[(self.data_category, type(self._data))]
-        self._key = (type(self._data), self._container_flavor)
-        dat.SupportsContainer.__init__(self)
-        # For completeness only, not required:
-        interface.SamplesInterface.__init__(self, self._data, self._container_flavor)
-
-    @property
-    def data(self):
-        return self.dispatch_to_implementation(self._key).data
-
-    def __str__(self) -> str:
-        return f"{self.__class__.__name__}:\n{self.data}"
-
-
-class StaticSamples(
-    Samples,
-    interface.StaticSamplesInterface,
-    dat.StaticSupportsContainer[impl.StaticSamplesImplementation],
-):
-    def __init__(self, data: dat.DataContainer, container_flavor: Optional[dat.ContainerFlavor] = None) -> None:
-        Samples.__init__(self, data, container_flavor)
-        dat.StaticSupportsContainer.__init__(self)
-        # For completeness only, not required:
-        interface.StaticSamplesInterface.__init__(self, self._data, self._container_flavor)
-
-    def as_data_frame(self):
-        return self.dispatch_to_implementation(self._key).as_data_frame()
-
-    def as_array(self):
-        return self.dispatch_to_implementation(self._key).as_array()
-
-    def _register_implementations(self) -> Dict[dat.DataContainerDef, impl.StaticSamplesImplementation]:
-        df_sample_x_feature_def = (pd.DataFrame, dat.ContainerFlavor.DF_SAMPLE_X_FEATURE)
-        df_sample_x_feature_impl = impl.df_samples.StaticSamples(
-            data=self._data, container_flavor=self._container_flavor  # type: ignore
-        )
-        return {
-            df_sample_x_feature_def: df_sample_x_feature_impl,
-        }
-
-
-class TimeSeriesSamples(
-    Samples,
-    interface.TimeSeriesSamplesInterface,
-    dat.TimeSeriesSupportsContainer[impl.TimeSeriesSamplesImplementation],
-):
-    def __init__(self, data: dat.DataContainer, container_flavor: Optional[dat.ContainerFlavor] = None) -> None:
-        Samples.__init__(self, data, container_flavor)
-        dat.TimeSeriesSupportsContainer.__init__(self)
-        # For completeness only, not required:
-        interface.TimeSeriesSamplesInterface.__init__(self, self._data, self._container_flavor)
-
-    def as_data_frame(self):
-        return self.dispatch_to_implementation(self._key).as_data_frame()
-
-    def as_array(self, padding_indicator: Any):
-        return self.dispatch_to_implementation(self._key).as_array(padding_indicator)
-
-    def _register_implementations(self) -> Dict[dat.DataContainerDef, impl.TimeSeriesSamplesImplementation]:
-        df_sample_timestep_x_feature_def = (pd.DataFrame, dat.ContainerFlavor.DF_SAMPLE_TIMESTEP_X_FEATURE)
-        df_sample_timestep_x_feature_impl = impl.df_samples.TimeSeriesSamples(
-            data=self._data, container_flavor=self._container_flavor  # type: ignore
-        )
-        return {
-            df_sample_timestep_x_feature_def: df_sample_timestep_x_feature_impl,
-        }
-
-
-class EventSamples(
-    Samples,
-    interface.EventSamplesInterface,
-    dat.EventSupportsContainer[impl.EventSamplesImplementation],
-):
-    def __init__(self, data: dat.DataContainer, container_flavor: Optional[dat.ContainerFlavor] = None) -> None:
-        Samples.__init__(self, data, container_flavor)
-        dat.EventSupportsContainer.__init__(self)
-        # For completeness only, not required:
-        interface.EventSamplesInterface.__init__(self, self._data, self._container_flavor)
-
-    def as_data_frame(self):
-        return self.dispatch_to_implementation(self._key).as_data_frame()
-
-    def as_array(self):
-        return self.dispatch_to_implementation(self._key).as_array()
-
-    def _register_implementations(self) -> Dict[dat.DataContainerDef, impl.EventSamplesImplementation]:
-        df_sample_x_time_event_def = (pd.DataFrame, dat.ContainerFlavor.DF_SAMPLE_TIME_X_EVENT)
-        df_sample_x_time_event_impl = impl.df_samples.EventSamples(
-            data=self._data, container_flavor=self._container_flavor  # type: ignore
-        )
-        return {
-            df_sample_x_time_event_def: df_sample_x_time_event_impl,
-        }
diff --git a/src/tempor/data/samples/impl/__init__.py b/src/tempor/data/samples/impl/__init__.py
deleted file mode 100644
index 4fd7d385..00000000
--- a/src/tempor/data/samples/impl/__init__.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from . import _df as df_samples
-from ._impl import (
-    EventSamplesImplementation,
-    StaticSamplesImplementation,
-    TimeSeriesSamplesImplementation,
-)
-
-__all__ = [
-    "df_samples",
-    "EventSamplesImplementation",
-    "StaticSamplesImplementation",
-    "TimeSeriesSamplesImplementation",
-]
diff --git a/src/tempor/data/samples/impl/_df.py b/src/tempor/data/samples/impl/_df.py
deleted file mode 100644
index 833ab048..00000000
--- a/src/tempor/data/samples/impl/_df.py
+++ /dev/null
@@ -1,56 +0,0 @@
-from typing import TYPE_CHECKING, Any
-
-import pandas as pd
-
-import tempor.data as dat
-from tempor.data import utils
-
-from . import _impl as impl
-
-
-class SamplesImplementationDF(impl.SamplesImplementation):
-    # For any reused functionality in child {Static,TimeSeries,Event}SamplesImplementationDF.
-
-    @property
-    def data(self) -> pd.DataFrame:
-        if TYPE_CHECKING:
-            assert isinstance(self._data, pd.DataFrame)
-        return self._data
-
-
-class StaticSamples(SamplesImplementationDF, impl.StaticSamplesImplementation):
-    def __init__(self, data: pd.DataFrame, container_flavor: dat.ContainerFlavor) -> None:
-        SamplesImplementationDF.__init__(self, data, container_flavor)
-        impl.StaticSamplesImplementation.__init__(self, data, container_flavor)
-
-    def as_data_frame(self):
-        return self.data
-
-    def as_array(self):
-        return self.data.to_numpy()
-
-
-class TimeSeriesSamples(SamplesImplementationDF, impl.TimeSeriesSamplesImplementation):
-    def __init__(self, data: pd.DataFrame, container_flavor: dat.ContainerFlavor) -> None:
-        SamplesImplementationDF.__init__(self, data, container_flavor)
-        impl.TimeSeriesSamplesImplementation.__init__(self, data, container_flavor)
-
-    def as_data_frame(self):
-        return self._data
-
-    def as_array(self, padding_indicator: Any):
-        return utils.multiindex_timeseries_df_to_array(
-            df=self.data, padding_indicator=padding_indicator, max_timesteps=None
-        )
-
-
-class EventSamples(SamplesImplementationDF, impl.EventSamplesImplementation):
-    def __init__(self, data: pd.DataFrame, container_flavor: dat.ContainerFlavor) -> None:
-        SamplesImplementationDF.__init__(self, data, container_flavor)
-        impl.EventSamplesImplementation.__init__(self, data, container_flavor)
-
-    def as_data_frame(self):
-        return self._data
-
-    def as_array(self):
-        return self.data.to_numpy()
diff --git a/src/tempor/data/samples/impl/_impl.py b/src/tempor/data/samples/impl/_impl.py
deleted file mode 100644
index 8c480831..00000000
--- a/src/tempor/data/samples/impl/_impl.py
+++ /dev/null
@@ -1,36 +0,0 @@
-import tempor.data as dat
-from tempor.data import validator as v
-from tempor.data.samples import interface
-
-
-class SamplesImplementation:
-    # For any reused functionality in child {Static,TimeSeries,Event}SamplesImplementation.
-
-    def __init__(self, data: dat.DataContainer, container_flavor: dat.ContainerFlavor) -> None:
-        self._data = data
-        self._container_flavor = container_flavor
-
-    @property
-    def data(self):
-        return self._data
-
-
-class StaticSamplesImplementation(SamplesImplementation, interface.StaticSamplesInterface):
-    def __init__(self, data: dat.DataContainer, container_flavor: dat.ContainerFlavor) -> None:
-        self._data = v.StaticDataValidator().validate(data=data, requirements=[], container_flavor=container_flavor)
-        SamplesImplementation.__init__(self, data, container_flavor)
-        interface.StaticSamplesInterface.__init__(self, data, container_flavor)  # For completeness.
-
-
-class TimeSeriesSamplesImplementation(SamplesImplementation, interface.TimeSeriesSamplesInterface):
-    def __init__(self, data: dat.DataContainer, container_flavor: dat.ContainerFlavor) -> None:
-        self._data = v.TimeSeriesDataValidator().validate(data=data, requirements=[], container_flavor=container_flavor)
-        SamplesImplementation.__init__(self, data, container_flavor)
-        interface.TimeSeriesSamplesInterface.__init__(self, data, container_flavor)  # For completeness.
-
-
-class EventSamplesImplementation(SamplesImplementation, interface.EventSamplesInterface):
-    def __init__(self, data: dat.DataContainer, container_flavor: dat.ContainerFlavor) -> None:
-        self._data = v.EventDataValidator().validate(data=data, requirements=[], container_flavor=container_flavor)
-        SamplesImplementation.__init__(self, data, container_flavor)
-        interface.EventSamplesInterface.__init__(self, data, container_flavor)  # For completeness.
diff --git a/src/tempor/data/samples/interface.py b/src/tempor/data/samples/interface.py
deleted file mode 100644
index 06448d64..00000000
--- a/src/tempor/data/samples/interface.py
+++ /dev/null
@@ -1,41 +0,0 @@
-from abc import ABC, abstractmethod
-from typing import Optional
-
-import numpy as np
-import pandas as pd
-
-import tempor.data as dat
-
-
-class SamplesInterface(ABC):
-    @abstractmethod
-    def __init__(self, data: dat.DataContainer, container_flavor: Optional[dat.ContainerFlavor] = None) -> None:
-        ...
-
-    @property
-    @abstractmethod
-    def data(self) -> dat.DataContainer:
-        ...
-
-    @abstractmethod
-    def as_data_frame(self) -> pd.DataFrame:
-        ...
-
-    def as_df(self) -> pd.DataFrame:  # Alias.
-        return self.as_data_frame()
-
-
-class StaticSamplesInterface(SamplesInterface):
-    @abstractmethod
-    def as_array(self) -> np.ndarray:
-        ...
-
-
-class TimeSeriesSamplesInterface(SamplesInterface):
-    @abstractmethod
-    def as_array(self, padding_indicator) -> np.ndarray:
-        ...
-
-
-class EventSamplesInterface(SamplesInterface):
-    pass
diff --git a/src/tempor/data/settings.py b/src/tempor/data/settings.py
new file mode 100644
index 00000000..264e6ed7
--- /dev/null
+++ b/src/tempor/data/settings.py
@@ -0,0 +1,40 @@
+"""Settings for TemporAI data handling.
+"""
+
+from typing import ClassVar, List
+
+import pydantic
+
+from . import data_typing
+
+
+@pydantic.dataclasses.dataclass(frozen=True)
+class DataSettings:
+    """Dataclass containing TemporAI data settings, such as configuration for data validation."""
+
+    static_value_dtypes: ClassVar[List[data_typing.Dtype]] = [bool, int, float, "category"]
+    time_series_value_dtypes: ClassVar[List[data_typing.Dtype]] = [bool, int, float, "category"]
+    event_value_dtypes: ClassVar[List[data_typing.Dtype]] = [bool]
+
+    sample_index_dtypes: ClassVar[List[data_typing.Dtype]] = [int, str]
+    time_index_dtypes: ClassVar[List[data_typing.Dtype]] = ["datetime", int, float]
+    feature_index_dtypes: ClassVar[List[data_typing.Dtype]] = [str]
+
+    sample_index_unique: ClassVar[bool] = True
+    sample_timestep_index_unique: ClassVar[bool] = True
+    sample_index_nullable: ClassVar[bool] = False
+    time_index_nullable: ClassVar[bool] = False
+    feature_index_nullable: ClassVar[bool] = False
+
+    static_values_nullable: ClassVar[bool] = True
+    time_series_values_nullable: ClassVar[bool] = True
+    event_values_nullable: ClassVar[bool] = False
+
+    sample_index_name: str = "sample_idx"
+    time_index_name: str = "time_idx"
+
+    default_padding_indicator: float = 999.0
+
+
+DATA_SETTINGS = DataSettings()
+"""TemporAI data settings."""
diff --git a/src/tempor/data/utils.py b/src/tempor/data/utils.py
index f01ef275..fb29aa4d 100644
--- a/src/tempor/data/utils.py
+++ b/src/tempor/data/utils.py
@@ -1,16 +1,56 @@
-from typing import Any, Optional
+import dataclasses
+import itertools
+from typing import Any, ClassVar, List, Optional
 
 import numpy as np
 import pandas as pd
+import pydantic
 
+from . import data_typing
 
-def value_in_df(df: pd.DataFrame, /, *, value: Any) -> bool:
-    return (np.isnan(value) and df.isna().any().any()) or (df == value).any().any()
 
+@dataclasses.dataclass(frozen=True)
+class _ExceptionMessages:
+    expected_array1d: ClassVar[str] = "Expected 1d array"
+    expected_array2d: ClassVar[str] = "Expected 2d array"
+    expected_array3d: ClassVar[str] = "Expected 3d array"
+    expected_array_dtype_bool: ClassVar[str] = "Expected array of dtype bool"
+    padding_indicator_nan_not_supported: ClassVar[str] = "Padding indicator of `numpy.nan` is not supported"
 
-def multiindex_timeseries_df_to_array(
-    df: pd.DataFrame, padding_indicator: Any, max_timesteps: Optional[int] = None
+
+EXCEPTION_MESSAGES = _ExceptionMessages()
+"""Reusable error messages for the module."""
+
+
+# --- Multiindex timeseries dataframe --> 3D numpy array. ---
+
+
+def value_in_df(df: pd.DataFrame, *, value: Any) -> bool:
+    """Check if `value` exists in dataframe `df`, accounting for the case where `value` is `nan`."""
+    return (pd.isnull(value) and df.isna().any().any()) or (df == value).any().any()
+
+
+@pydantic.validate_arguments(config={"arbitrary_types_allowed": True, "smart_union": True})
+def multiindex_timeseries_dataframe_to_array3d(
+    df: pd.DataFrame, *, padding_indicator: Any, max_timesteps: Optional[int] = None
 ) -> np.ndarray:
+    """Convert timeseries dataframe `df` with a 2-level multiindex (sample, timestep) to a 3D numpy array with
+    dimensions `(sample, timestep, feature)`.
+
+    Args:
+        df (pd.DataFrame): Input dataframe
+        padding_indicator (Any): padding indicator value to use to pad the output array in case of unequal number of
+        timesteps for different samples.
+        max_timesteps (int, optional): Maximum number of timesteps to use. This will become the size of the dim 1 of the
+        output array. If set to `None`, this dimension will be set as the highest number of timesteps among the samples.
+        Defaults to None.
+
+    Raises:
+        ValueError: raised if the `padding_indicator` found as one of the data values in `df`.
+
+    Returns:
+        np.ndarray: Output 3D numpy array.
+    """
     if value_in_df(df, value=padding_indicator):
         raise ValueError(f"Value `{padding_indicator}` found in data frame, choose a different padding indicator")
     samples = df.index.get_level_values(level=0).unique()
@@ -24,3 +64,220 @@ def multiindex_timeseries_df_to_array(
         set_vals = df.loc[idx_sample, :, :].to_numpy()[:max_timesteps, :]  # pyright: ignore
         array[i_sample, : num_timesteps_per_sample[idx_sample], :] = set_vals  # pyright: ignore
     return array
+
+
+# --- 3D numpy array --> Multiindex timeseries dataframe. ---
+
+
+def check_bool_array1d_trues_consecutive(array: np.ndarray, at_beginning: bool = False, at_end: bool = False) -> bool:
+    """Check if 1D `array` (containing `bool` values) has all `True` elements consecutively. If `at_{beginning,end}` is
+    set, will also check that a `True` element is present as the first or last element of the `array`, respectively.
+    Raises `ValueError` if input `array` format is unexpected.
+
+    Examples:
+    >>> import numpy as np
+    >>> check_bool_array1d_trues_consecutive(np.asarray([False, True, True, True, False]))
+    True
+    >>> check_bool_array1d_trues_consecutive(np.asarray([False, True, False, True, False]))
+    False
+    >>> check_bool_array1d_trues_consecutive(np.asarray([False, True, True, True]), at_end=True)
+    True
+    """
+    if array.ndim != 1:
+        raise ValueError(EXCEPTION_MESSAGES.expected_array1d)
+    if array.dtype != bool:
+        raise ValueError(EXCEPTION_MESSAGES.expected_array_dtype_bool)
+    if len(array) == 0:
+        return True
+    true_idxs = np.where(array)[0]
+    if len(true_idxs) == 0:
+        return True
+    if not (np.ediff1d(true_idxs) == 1).all():
+        return False
+    else:
+        check_passed = True
+        if at_beginning:
+            check_passed = check_passed and 0 in true_idxs
+        if at_end:
+            check_passed = check_passed and (len(array) - 1) in true_idxs
+        return check_passed
+
+
+def check_bool_array2d_identical_along_dim1(array: np.ndarray) -> bool:
+    """Check if 2D `array` (containing `bool` values) has the same values along dimension 1.
+
+    Examples:
+    >>> import numpy as np
+    >>> check_bool_array2d_identical_along_dim1(np.asarray([[True, True, False], [True, True, False]]).T)
+    True
+    >>> check_bool_array2d_identical_along_dim1(np.asarray([[True, True, False], [False, True, False]]).T)
+    False
+    """
+    if array.ndim != 2:
+        raise ValueError(EXCEPTION_MESSAGES.expected_array2d)
+    if array.dtype != bool:
+        raise ValueError(EXCEPTION_MESSAGES.expected_array_dtype_bool)
+    return (np.diff(array.astype(int), axis=1) == 0).all()
+
+
+def get_array1d_length_until_padding(array: np.ndarray, padding_indicator: Any = None) -> int:
+    """Get the length of 1D `array` up to first padding indicated by `padding_indicator`. Raises `ValueError` if input
+    `array` format is unexpected.
+
+    Examples:
+    >>> import numpy as np
+    >>> pad = 999.0
+    >>> get_array1d_length_until_padding(np.asarray([1, 8, -3, 9, pad]), padding_indicator=pad)
+    4
+    >>> get_array1d_length_until_padding(np.asarray([1, 8, -3, 9, 5]), padding_indicator=pad)
+    5
+    """
+    if array.ndim != 1:
+        raise ValueError(EXCEPTION_MESSAGES.expected_array1d)
+    if np.isnan(padding_indicator):
+        raise ValueError(EXCEPTION_MESSAGES.padding_indicator_nan_not_supported)
+    array_padding = array == padding_indicator
+    positions = np.where(array_padding)[0]
+    if len(positions) != 0:
+        return positions[0]
+    else:
+        return len(array)
+
+
+def validate_timeseries_array3d(array: np.ndarray, padding_indicator: Any = None):
+    """Check if 3D `array` representing timeseries satisfies the blow criteria, otherwise raise ValueError:
+    - 3 dimensions,
+    - Dimension 2 not of size 0,
+    - If `padding_indicator` is provided, also check it is not `np.nan`, as this is not supported.
+    """
+    if array.ndim != 3:
+        raise ValueError(EXCEPTION_MESSAGES.expected_array3d)
+    if array.shape[2] == 0:
+        raise ValueError("Dim 2 (-1) is the feature dimension and must not be of size 0")
+    if padding_indicator is not None and np.isnan(padding_indicator):
+        raise ValueError(EXCEPTION_MESSAGES.padding_indicator_nan_not_supported)
+
+
+def get_seq_lengths_timeseries_array3d(array: np.ndarray, padding_indicator: Any = None) -> List[int]:
+    """Given a 3D numpy `array` that represents timeseries like `(sample, timestep, feature)`, and optionally a
+    `padding_indicator` to indicate padding, get the length (number of [non-padding] timesteps) for each sample.
+
+    Example:
+    >>> import numpy as np
+    >>> pad = 999.0
+    >>> array = np.asarray(  # Array with two samples, with two timeseries features.
+    ...     [
+    ...         # Sample 1:
+    ...         [
+    ...             [11, 12, 13, 14, pad],
+    ...             [1.1, 1.2, 1.3, 1.4, pad],
+    ...         ],
+    ...         # Sample 2:
+    ...         [
+    ...             [21, 22, pad, pad, pad],
+    ...             [2.1, 2.2, pad, pad, pad],
+    ...         ],
+    ...     ]
+    ... )
+    >>> array = np.transpose(array, (0, 2, 1))
+    >>> get_seq_lengths_timeseries_array3d(array, padding_indicator=pad)
+    [4, 2]
+    """
+    validate_timeseries_array3d(array, padding_indicator)
+    is_padded = padding_indicator is not None
+    if is_padded:
+        lengths = []
+        for array_sample in array:
+            array_sample_padding = array_sample == padding_indicator
+            if not check_bool_array2d_identical_along_dim1(array_sample_padding):
+                raise ValueError(
+                    "Expected padding to be indicated identically across all features for each sample. "
+                    f"Problem sample as array:\n{array_sample}"
+                )
+            array_sample_feat0, array_sample_padding_feat0 = array_sample[:, 0], array_sample_padding[:, 0]
+            if not check_bool_array1d_trues_consecutive(array_sample_padding_feat0, at_end=True):
+                raise ValueError(
+                    "Expected all padding values to be consecutive and at the end. "
+                    f"Problem sample 0th feature as array:\n{array_sample[0, :]}"
+                )
+            n_timesteps = get_array1d_length_until_padding(array_sample_feat0, padding_indicator)
+            lengths.append(n_timesteps)
+        return lengths
+    else:
+        return [array.shape[1]] * array.shape[0]
+
+
+def unpad_timeseries_array3d(array: np.ndarray, padding_indicator: Any) -> List[np.ndarray]:
+    """Given a 3D numpy `array` that represents timeseries like `(sample, timestep, feature)`, and optionally a
+    `padding_indicator` to indicate padding, return a list of length `num_samples`, which contains arrays for each sample
+    like `(timestep, feature)`, with the padding removed.
+    """
+    validate_timeseries_array3d(array, padding_indicator)
+    lengths = get_seq_lengths_timeseries_array3d(array, padding_indicator)
+    arrays_unpadded = []
+    for sample_i, length in enumerate(lengths):
+        arrays_unpadded.append(array[sample_i, :length, :])
+    return arrays_unpadded
+
+
+def make_sample_time_index_tuples(
+    sample_index: data_typing.SampleIndex, time_indexes: data_typing.TimeIndexList
+) -> data_typing.SampleTimeIndexTuples:
+    """Given a list of elements `sample_index` representing sample IDs and a list (of same length) of lists each
+    representing the timesteps for the corresponding sample, return a list of tuples like
+    `[(<sample ID>, <timestep>), ...]`.
+
+    Example:
+    >>> sample_index = ["s1", "s2"]
+    >>> time_indexes = [[1, 2, 3], [1, 5, 9, 10]]
+    >>> make_sample_time_index_tuples(sample_index, time_indexes)
+    [('s1', 1), ('s1', 2), ('s1', 3), ('s2', 1), ('s2', 5), ('s2', 9), ('s2', 10)]
+    """
+    if len(sample_index) != len(time_indexes):
+        raise ValueError("Expected the same number of elements in `sample_index` and `time_indexes`")
+    sample_indexes_copied = [[si] * len(tis) for si, tis in zip(sample_index, time_indexes)]
+    sample_indexes_flattened = list(itertools.chain.from_iterable(sample_indexes_copied))
+    time_indexes_flattened = list(itertools.chain.from_iterable(time_indexes))
+    pairs = [(si, ti) for si, ti in zip(sample_indexes_flattened, time_indexes_flattened)]
+    return pairs  # type: ignore
+
+
+@pydantic.validate_arguments(config={"arbitrary_types_allowed": True, "smart_union": True})
+def array3d_to_multiindex_timeseries_dataframe(
+    array: np.ndarray,
+    *,
+    sample_index: data_typing.SampleIndex,
+    time_indexes: data_typing.TimeIndexList,
+    feature_index: data_typing.FeatureIndex,
+    padding_indicator: Any = None,
+) -> pd.DataFrame:
+    """Given a 3D timeseries `array`, `sample_index`, `time_indexes`, `feature_index`, and a `padding_indicator`, build
+    a 2-level multiindex (sample, timestep) `pandas.DataFrame`.
+
+    Padding values of `padding_indicator` can be set inside the array to pad out the length of arrays of different
+    samples in case they differ. Padding needs to go at the end of the timesteps (dim 1). Padding must be the same
+    across the feature dimension (dim 2) for each sample.
+
+    Raises:
+        ValueError: if data or padding format is unexpected.
+
+    Args:
+        array (np.ndarray): 3D numpy `array` that represents timeseries like `(sample, timestep, feature)`.
+        sample_index (List[<sample element>): List of sample IDs (should be the same length as dim 0 of `array`).
+        time_indexes (List[List[<timestep element>]]): List of lists containing timesteps for each sample (outer list
+        should be the same length as dim 0 of `array`, inner list should contain as many elements as each sample has
+        timesteps).
+        feature_index (List[<feature element>]): List of feature names.
+        padding_indicator (Any, optional): Padding indicator used in `array` to indicate padding. Defaults to None.
+
+    Returns:
+        pd.DataFrame: Resultant dataframe.
+    """
+    validate_timeseries_array3d(array, padding_indicator)
+    unpadded_arrays = unpad_timeseries_array3d(array, padding_indicator)
+    data = np.concatenate(unpadded_arrays)
+    return pd.DataFrame(
+        data,
+        index=pd.MultiIndex.from_tuples(make_sample_time_index_tuples(sample_index, time_indexes)),
+        columns=feature_index,
+    )
diff --git a/src/tempor/data/validator/__init__.py b/src/tempor/data/validator/__init__.py
deleted file mode 100644
index 5f471901..00000000
--- a/src/tempor/data/validator/__init__.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from ._validator import (
-    DataValidator,
-    EventDataValidator,
-    StaticDataValidator,
-    TimeSeriesDataValidator,
-)
-
-__all__ = [
-    "DataValidator",
-    "EventDataValidator",
-    "StaticDataValidator",
-    "TimeSeriesDataValidator",
-]
diff --git a/src/tempor/data/validator/_validator.py b/src/tempor/data/validator/_validator.py
deleted file mode 100644
index e9a491e8..00000000
--- a/src/tempor/data/validator/_validator.py
+++ /dev/null
@@ -1,45 +0,0 @@
-from typing import Dict, List
-
-import pandas as pd
-
-import tempor.data as dat
-import tempor.data.requirements as r
-
-from . import impl, interface
-
-
-class DataValidator(interface.DataValidatorInterface, dat.SupportsContainer[impl.ValidatorImplementation]):
-    def validate(
-        self, data: dat.DataContainer, requirements: List[r.DataRequirement], container_flavor: dat.ContainerFlavor
-    ) -> dat.DataContainer:
-        self.check_data_container_supported_types(data)
-        return self.dispatch_to_implementation((type(data), container_flavor)).validate(
-            data, requirements, container_flavor
-        )
-
-
-class StaticDataValidator(DataValidator, dat.StaticSupportsContainer):
-    def _register_implementations(self) -> Dict[dat.DataContainerDef, impl.ValidatorImplementation]:
-        df_sample_x_feature_def = (pd.DataFrame, dat.ContainerFlavor.DF_SAMPLE_X_FEATURE)
-        df_sample_x_feature_impl = impl.df_validator.StaticDataValidator()
-        return {
-            df_sample_x_feature_def: df_sample_x_feature_impl,
-        }
-
-
-class TimeSeriesDataValidator(DataValidator, dat.TimeSeriesSupportsContainer):
-    def _register_implementations(self) -> Dict[dat.DataContainerDef, impl.ValidatorImplementation]:
-        df_sample_x_feature_def = (pd.DataFrame, dat.ContainerFlavor.DF_SAMPLE_TIMESTEP_X_FEATURE)
-        df_sample_x_feature_impl = impl.df_validator.TimeSeriesDataValidator()
-        return {
-            df_sample_x_feature_def: df_sample_x_feature_impl,
-        }
-
-
-class EventDataValidator(DataValidator, dat.EventSupportsContainer):
-    def _register_implementations(self) -> Dict[dat.DataContainerDef, impl.ValidatorImplementation]:
-        df_sample_x_time_event_def = (pd.DataFrame, dat.ContainerFlavor.DF_SAMPLE_TIME_X_EVENT)
-        df_sample_x_time_event_impl = impl.df_validator.EventDataValidator()
-        return {
-            df_sample_x_time_event_def: df_sample_x_time_event_impl,
-        }
diff --git a/src/tempor/data/validator/impl/__init__.py b/src/tempor/data/validator/impl/__init__.py
deleted file mode 100644
index 4f9d067f..00000000
--- a/src/tempor/data/validator/impl/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from . import _df as df_validator
-from ._impl import RegisterValidation, ValidatorImplementation
-
-__all__ = [
-    "df_validator",
-    "ValidatorImplementation",
-    "RegisterValidation",
-]
diff --git a/src/tempor/data/validator/impl/_df.py b/src/tempor/data/validator/impl/_df.py
deleted file mode 100644
index 179f3b10..00000000
--- a/src/tempor/data/validator/impl/_df.py
+++ /dev/null
@@ -1,211 +0,0 @@
-from typing import Tuple
-
-import pandas as pd
-import pandera as pa
-
-import tempor.data as dat
-import tempor.data.requirements as r
-from tempor.data import DATA_SETTINGS as DS  # For brevity.
-from tempor.log import logger
-
-from . import _impl as impl
-from . import pandera_utils as pu
-
-
-class DataValidatorDF(impl.ValidatorImplementation):
-    @property
-    def schema(self):
-        return self._validation_records["schema"]
-
-    @schema.setter
-    def schema(self, value):
-        self._validation_records["schema"] = value
-
-    @impl.RegisterValidation.register_method_for(r.ValueDTypes)
-    def _(self, data: pd.DataFrame, req: r.DataRequirement) -> pd.DataFrame:
-        assert isinstance(req, r.ValueDTypes)
-        self.schema = pu.add_all_column_checks(
-            self.schema,
-            dtype=None,
-            nullable=True,
-            checks_list=[pu.checks.dynamic.values_are_one_of_dtypes(set(req.value_dtypes))],
-        )
-        return self.schema.validate(data)
-
-    @impl.RegisterValidation.register_method_for(r.AllowMissing)
-    def _(self, data: pd.DataFrame, req: r.DataRequirement) -> pd.DataFrame:
-        assert isinstance(req, r.AllowMissing)
-        self.schema = pu.add_all_column_checks(
-            self.schema,
-            dtype=None,
-            nullable=req.allow_missing,
-            checks_list=[],
-        )
-        return self.schema.validate(data)
-
-
-class StaticDataValidator(DataValidatorDF):
-    @property
-    def data_category(self) -> dat.DataCategory:
-        return dat.DataCategory.STATIC
-
-    def root_validate(self, data: pd.DataFrame) -> pd.DataFrame:
-        schema = pa.infer_schema(data)
-        assert isinstance(schema, pa.DataFrameSchema)
-        logger.debug(f"Inferred schema:\n{schema}")
-
-        # DF-wide:
-        schema = pu.add_df_wide_checks(
-            schema,
-            checks_list=[
-                pu.checks.forbid_multiindex_index,
-                pu.checks.forbid_multiindex_columns,
-                pu.checks.dynamic.column_index_is_one_of_dtypes(
-                    DS.feature_index_dtypes, nullable=DS.feature_index_nullable
-                ),
-            ],
-        )
-        schema.validate(data)
-
-        # (Column) values:
-        schema = pu.add_all_column_checks(
-            schema,
-            dtype=None,
-            nullable=DS.values_nullable,
-            checks_list=[pu.checks.dynamic.values_are_one_of_dtypes(DS.value_dtypes)],
-        )
-        data = schema.validate(data)
-
-        # Index:
-        schema, data = pu.set_up_index(
-            schema,
-            data,
-            name=DS.sample_index_name,
-            nullable=DS.sample_index_nullable,
-            unique=DS.sample_index_unique,
-            checks_list=[pu.checks.dynamic.index_is_one_of_dtypes(DS.sample_index_dtypes)],
-        )
-        data = schema.validate(data)
-        assert isinstance(data, pd.DataFrame)
-
-        logger.debug(f"Final schema:\n{schema}")
-
-        self.schema = schema
-        return data
-
-
-class TimeSeriesDataValidator(DataValidatorDF):
-    @property
-    def data_category(self) -> dat.DataCategory:
-        return dat.DataCategory.TIME_SERIES
-
-    def root_validate(self, data: pd.DataFrame) -> pd.DataFrame:
-        schema = pa.infer_schema(data)
-        assert isinstance(schema, pa.DataFrameSchema)
-        logger.debug(f"Inferred schema:\n{schema}")
-
-        # DF-wide:
-        schema = pu.add_df_wide_checks(
-            schema,
-            checks_list=[
-                pu.checks.forbid_multiindex_columns,
-                pu.checks.require_2level_multiindex_index,
-                pu.checks.dynamic.column_index_is_one_of_dtypes(
-                    DS.feature_index_dtypes, nullable=DS.feature_index_nullable
-                ),
-            ],
-        )
-        schema.validate(data)
-
-        # (Column) values:
-        schema = pu.add_all_column_checks(
-            schema,
-            dtype=None,
-            nullable=DS.values_nullable,
-            checks_list=[pu.checks.dynamic.values_are_one_of_dtypes(DS.value_dtypes)],
-        )
-        schema.validate(data)
-
-        # Index:
-        multiindex_unique_def: Tuple[str, ...] = tuple()
-        if DS.sample_index_unique:
-            multiindex_unique_def = (DS.sample_index_name,)
-        if DS.sample_timestep_index_unique:
-            multiindex_unique_def = (DS.sample_index_name, DS.time_index_name)
-        schema, data = pu.set_up_2level_multiindex(
-            schema,
-            data,
-            names=(DS.sample_index_name, DS.time_index_name),
-            nullable=(DS.sample_index_nullable, DS.time_index_nullable),
-            unique=multiindex_unique_def,
-            checks_list=(
-                [pu.checks.dynamic.index_is_one_of_dtypes(DS.feature_index_dtypes)],
-                [pu.checks.dynamic.index_is_one_of_dtypes(DS.time_index_dtypes)],
-            ),
-        )
-        data = schema.validate(data)
-        assert isinstance(data, pd.DataFrame)
-
-        logger.debug(f"Final schema:\n{schema}")
-
-        self.schema = schema
-        return data
-
-
-class EventDataValidator(DataValidatorDF):
-    @property
-    def data_category(self) -> dat.DataCategory:
-        return dat.DataCategory.EVENT
-
-    def root_validate(self, data: pd.DataFrame) -> pd.DataFrame:
-        schema = pa.infer_schema(data)
-        assert isinstance(schema, pa.DataFrameSchema)
-        logger.debug(f"Inferred schema:\n{schema}")
-
-        # DF-wide:
-        schema = pu.add_df_wide_checks(
-            schema,
-            checks_list=[
-                pu.checks.forbid_multiindex_columns,
-                pu.checks.require_2level_multiindex_index,
-                pu.checks.require_2level_multiindex_one_to_one,  # NOTE this.
-                pu.checks.dynamic.column_index_is_one_of_dtypes(
-                    DS.feature_index_dtypes, nullable=DS.feature_index_nullable
-                ),
-            ],
-        )
-        schema.validate(data)
-
-        # (Column) values:
-        schema = pu.add_all_column_checks(
-            schema,
-            dtype=None,
-            nullable=DS.values_nullable,
-            checks_list=[pu.checks.dynamic.values_are_one_of_dtypes(DS.value_dtypes)],
-        )
-        schema.validate(data)
-
-        # Index:
-        multiindex_unique_def: Tuple[str, ...] = tuple()
-        if DS.sample_index_unique:
-            multiindex_unique_def = (DS.sample_index_name,)
-        if DS.sample_timestep_index_unique:
-            multiindex_unique_def = (DS.sample_index_name, DS.time_index_name)
-        schema, data = pu.set_up_2level_multiindex(
-            schema,
-            data,
-            names=(DS.sample_index_name, DS.time_index_name),
-            nullable=(DS.sample_index_nullable, DS.time_index_nullable),
-            unique=multiindex_unique_def,
-            checks_list=(
-                [pu.checks.dynamic.index_is_one_of_dtypes(DS.feature_index_dtypes)],
-                [pu.checks.dynamic.index_is_one_of_dtypes(DS.time_index_dtypes)],
-            ),
-        )
-        data = schema.validate(data)
-        assert isinstance(data, pd.DataFrame)
-
-        logger.debug(f"Final schema:\n{schema}")
-
-        self.schema = schema
-        return data
diff --git a/src/tempor/data/validator/impl/_impl.py b/src/tempor/data/validator/impl/_impl.py
deleted file mode 100644
index cc1e0877..00000000
--- a/src/tempor/data/validator/impl/_impl.py
+++ /dev/null
@@ -1,63 +0,0 @@
-from abc import ABC, abstractmethod
-from typing import Callable, Dict, List, Type
-
-import tempor.core
-import tempor.data as dat
-import tempor.data.requirements as r
-import tempor.exc
-from tempor.data.validator import interface
-from tempor.log import log_helpers, logger
-
-_ValidationMethod = Callable[["ValidatorImplementation", dat.DataContainer, r.DataRequirement], dat.DataContainer]
-
-
-class ValidatorImplementation(interface.DataValidatorInterface, ABC):
-    validation_methods: Dict[Type[r.DataRequirement], _ValidationMethod]
-    _validation_records: Dict
-
-    def __init__(self) -> None:
-        super().__init__()
-
-        # Check all data requirements are handled:
-        expected_reqs = r.DATA_REQUIREMENTS[self.data_category]
-        found_reqs = set(self.validation_methods.keys())
-        if expected_reqs != found_reqs:
-            raise TypeError(
-                f"Expected {self.__class__.__name__} to have handled the following data requirements in "
-                f"its validation methods {list(expected_reqs)} but found {list(found_reqs)}"
-            )
-
-    @property
-    @abstractmethod
-    def data_category(self) -> dat.DataCategory:  # pragma: no cover
-        ...
-
-    @abstractmethod
-    def root_validate(self, data: dat.DataContainer) -> dat.DataContainer:  # pragma: no cover
-        # Always do this validation.
-        ...
-
-    def validate(
-        self, data: dat.DataContainer, requirements: List[r.DataRequirement], container_flavor: dat.ContainerFlavor
-    ) -> dat.DataContainer:
-        with log_helpers.exc_to_log():
-            try:
-                self._validation_records = dict()
-                logger.debug("Doing root validation")
-                data = self.root_validate(data)
-                for req in requirements:
-                    logger.debug(f"Validating requirement {req}")
-                    data = self.validation_methods[type(req)](self, data, req)
-                self._validation_records = dict()
-            except Exception as ex:
-                raise tempor.exc.DataValidationFailedException(
-                    "Data validation failed, see traceback for more details"
-                ) from ex
-        return data
-
-
-class RegisterValidation(tempor.core.RegisterMethodDecorator):
-    owner_class: type = ValidatorImplementation
-    registration_dict_attribute_name: str = "validation_methods"
-    key_type: type = r.DataRequirement
-    method_category_name: str = "validation"
diff --git a/src/tempor/data/validator/interface.py b/src/tempor/data/validator/interface.py
deleted file mode 100644
index 4af390cd..00000000
--- a/src/tempor/data/validator/interface.py
+++ /dev/null
@@ -1,14 +0,0 @@
-from abc import ABC, abstractmethod
-from typing import List
-
-import tempor.data as dat
-import tempor.data._types as types
-import tempor.data.requirements as r
-
-
-class DataValidatorInterface(ABC):
-    @abstractmethod
-    def validate(
-        self, data: types.DataContainer, requirements: List[r.DataRequirement], container_flavor: dat.ContainerFlavor
-    ) -> types.DataContainer:  # pragma: no cover
-        ...
diff --git a/src/tempor/exc/__init__.py b/src/tempor/exc/__init__.py
index 2ee52102..b113f4a8 100644
--- a/src/tempor/exc/__init__.py
+++ b/src/tempor/exc/__init__.py
@@ -1,2 +1,2 @@
-class DataValidationFailedException(Exception):
+class DataValidationException(ValueError):
     pass
diff --git a/src/tempor/plugins/__init__.py b/src/tempor/plugins/__init__.py
new file mode 100644
index 00000000..44d87a10
--- /dev/null
+++ b/src/tempor/plugins/__init__.py
@@ -0,0 +1,5 @@
+from ._plugin_loader import plugin_loader
+
+__all__ = [
+    "plugin_loader",
+]
diff --git a/src/tempor/plugins/_plugin_loader.py b/src/tempor/plugins/_plugin_loader.py
new file mode 100644
index 00000000..570da2ca
--- /dev/null
+++ b/src/tempor/plugins/_plugin_loader.py
@@ -0,0 +1,10 @@
+import tempor.plugins.core as plugins
+
+from . import preprocessing
+
+plugin_loader = plugins.PluginLoader()
+
+
+__all__ = [
+    "preprocessing",
+]
diff --git a/src/tempor/plugins/core/__init__.py b/src/tempor/plugins/core/__init__.py
new file mode 100644
index 00000000..537ca968
--- /dev/null
+++ b/src/tempor/plugins/core/__init__.py
@@ -0,0 +1,21 @@
+from ._base_estimator import BaseEstimator
+from ._base_predictor import BasePredictor
+from ._base_transformer import BaseTransformer
+from ._plugin import (
+    Plugin,
+    PluginLoader,
+    importing,
+    register_plugin,
+    register_plugin_category,
+)
+
+__all__ = [
+    "BaseEstimator",
+    "importing",
+    "Plugin",
+    "PluginLoader",
+    "BasePredictor",
+    "register_plugin_category",
+    "register_plugin",
+    "BaseTransformer",
+]
diff --git a/src/tempor/plugins/core/_base_estimator.py b/src/tempor/plugins/core/_base_estimator.py
new file mode 100644
index 00000000..d10dced8
--- /dev/null
+++ b/src/tempor/plugins/core/_base_estimator.py
@@ -0,0 +1,112 @@
+import abc
+import dataclasses
+from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Optional, Type
+
+import omegaconf
+import pydantic
+import rich.pretty
+
+import tempor.core.utils
+from tempor.data import dataset
+from tempor.log import logger
+
+from ._params import Params
+from ._plugin import Plugin
+
+
+@dataclasses.dataclass
+class EmptyParamsDefinition:
+    pass
+
+
+class BaseEstimator(Plugin, abc.ABC):
+    PARAMS_DEFINITION: ClassVar[Type] = EmptyParamsDefinition
+    _fitted: bool
+
+    class _InitArgsValidator(pydantic.BaseModel):
+        params: Optional[Dict[str, Any]]
+        ParamsDefinitionClass: Type
+
+        # Output:
+        params_processed: Optional[omegaconf.DictConfig] = None
+
+        @pydantic.root_validator
+        def root_checks(cls, values: Dict):  # pylint: disable=no-self-argument
+            params = values.get("params", dict())
+            ParamsDefinitionClass = values.get("ParamsDefinitionClass")
+
+            if TYPE_CHECKING:  # pragma: no cover
+                assert ParamsDefinitionClass is not None
+
+            try:
+                defined_params = omegaconf.OmegaConf.structured(ParamsDefinitionClass(**params))
+            except Exception as ex:
+                name = tempor.core.utils.get_class_full_name(ex)
+                sep = "\n" + "-" * (len(name) + 1) + "\n"
+                raise ValueError(
+                    "Model parameters could not be converted to OmegaConf Structured Config "
+                    f"as defined by `{ParamsDefinitionClass.__name__}`, cause: {sep}{name}:\n{ex}{sep}"
+                ) from ex
+
+            values["params_processed"] = defined_params
+            return values
+
+        class Config:
+            arbitrary_types_allowed = True
+
+    def __init__(self, **params) -> None:
+        Plugin.__init__(self)
+        self._fitted = False
+        args_validator = self._InitArgsValidator(params=params, ParamsDefinitionClass=self.PARAMS_DEFINITION)
+        params_processed = args_validator.params_processed
+        print(params_processed)
+        if TYPE_CHECKING:  # pragma: no cover
+            assert isinstance(params_processed, omegaconf.DictConfig)
+        self.params = params_processed
+
+    @property
+    def is_fitted(self) -> bool:
+        """Check if the model was trained"""
+        return self._fitted
+
+    def __rich_repr__(self):
+        yield "name", self.name
+        yield "category", self.category
+        yield "params", omegaconf.OmegaConf.to_container(self.params)
+
+    def __repr__(self) -> str:
+        return rich.pretty.pretty_repr(self)
+
+    def fit(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> "BaseEstimator":
+        logger.debug(f"Calling _fit() implementation on {self.__class__.__name__}")
+        fitted_model = self._fit(data, *args, **kwargs)
+
+        self._fitted = True
+        return fitted_model
+
+    @abc.abstractmethod
+    def _fit(self, data: dataset.Dataset, *args, **kwargs) -> "BaseEstimator":  # pragma: no cover
+        ...
+
+    @staticmethod
+    @abc.abstractmethod
+    def hyperparameter_space(*args: Any, **kwargs: Any) -> List[Params]:  # pragma: no cover
+        """The hyperparameter search domain, used for tuning."""
+        ...  # pylint: disable=unnecessary-ellipsis
+
+    @classmethod
+    def sample_hyperparameters(cls, trial: Any, *args: Any, **kwargs: Any) -> Dict[str, Any]:
+        """Sample hyperparameters."""
+        param_space = cls.hyperparameter_space(*args, **kwargs)
+
+        results = {}
+
+        for hp in param_space:
+            results[hp.name] = hp.sample(trial)
+
+        return results
diff --git a/src/tempor/plugins/core/_base_predictor.py b/src/tempor/plugins/core/_base_predictor.py
new file mode 100644
index 00000000..afdbc940
--- /dev/null
+++ b/src/tempor/plugins/core/_base_predictor.py
@@ -0,0 +1,36 @@
+import abc
+from typing import Any
+
+from tempor.data import dataset
+from tempor.log import logger
+
+from . import _base_estimator as estimator
+
+
+class BasePredictor(estimator.BaseEstimator):
+    def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegation
+        super().__init__(**params)
+
+    def predict(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> Any:  # TODO: Narrow down output formats later.
+        logger.debug(f"Calling _predict() implementation on {self.__class__.__name__}")
+        prediction = self._predict(data, *args, **kwargs)
+
+        return prediction
+
+    def fit_predict(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> Any:
+        self.fit(data, *args, **kwargs)
+        return self.predict(data, *args, **kwargs)
+
+    @abc.abstractmethod
+    def _predict(self, data: dataset.Dataset, *args, **kwargs) -> Any:  # pragma: no cover
+        ...
diff --git a/src/tempor/plugins/core/_base_transformer.py b/src/tempor/plugins/core/_base_transformer.py
new file mode 100644
index 00000000..48e201bf
--- /dev/null
+++ b/src/tempor/plugins/core/_base_transformer.py
@@ -0,0 +1,36 @@
+import abc
+from typing import Any
+
+from tempor.data import dataset
+from tempor.log import logger
+
+from . import _base_estimator as estimator
+
+
+class BaseTransformer(estimator.BaseEstimator):
+    def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegation
+        super().__init__(**params)
+
+    def transform(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> Any:
+        logger.debug(f"Calling _transform() implementation on {self.__class__.__name__}")
+        transformed_data = self._transform(data, *args, **kwargs)
+
+        return transformed_data
+
+    def fit_transform(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> Any:
+        self.fit(data, *args, **kwargs)
+        return self.transform(data, *args, **kwargs)
+
+    @abc.abstractmethod
+    def _transform(self, data: dataset.Dataset, *args, **kwargs) -> Any:  # pragma: no cover
+        ...
diff --git a/src/tempor/plugins/core/_params.py b/src/tempor/plugins/core/_params.py
new file mode 100644
index 00000000..70e274e7
--- /dev/null
+++ b/src/tempor/plugins/core/_params.py
@@ -0,0 +1,20 @@
+import abc
+from typing import Any, List, Tuple
+
+
+class Params(abc.ABC):
+    """
+    Helper for describing the hyperparameters for each estimator.
+    """
+
+    def __init__(self, name: str, bounds: Tuple[Any, Any]) -> None:
+        self.name = name
+        self.bounds = bounds
+
+    @abc.abstractmethod
+    def get(self) -> List[Any]:
+        ...
+
+    @abc.abstractmethod
+    def sample(self, trial: Any) -> Any:
+        ...
diff --git a/src/tempor/plugins/core/_plugin.py b/src/tempor/plugins/core/_plugin.py
new file mode 100644
index 00000000..25fdb55a
--- /dev/null
+++ b/src/tempor/plugins/core/_plugin.py
@@ -0,0 +1,167 @@
+import functools
+import glob
+import importlib.abc
+import importlib.util
+import os.path
+from typing import Any, ClassVar, Dict, List, Type
+
+from tempor.log import logger
+
+from . import utils
+
+PLUGIN_NAME_NOT_SET = "NOT_SET"
+PLUGIN_CATEGORY_NOT_SET = "NOT_SET"
+
+
+class Plugin:
+    name: ClassVar[str] = PLUGIN_NAME_NOT_SET
+    category: ClassVar[str] = PLUGIN_CATEGORY_NOT_SET
+
+    @classmethod
+    def fqn(cls) -> str:
+        """The fully-qualified name of the plugin: category.name"""
+        return f"{cls.category}.{cls.name}"
+
+    def __init__(self) -> None:
+        if self.name == PLUGIN_NAME_NOT_SET:
+            raise ValueError(f"Plugin {self.__class__.__name__} `name` was not set, use @{register_plugin.__name__}")
+        if self.category == PLUGIN_CATEGORY_NOT_SET:
+            raise ValueError(
+                f"Plugin {self.__class__.__name__} `category` was not set, use @{register_plugin.__name__}"
+            )
+
+
+PLUGIN_CATEGORY_REGISTRY: Dict[str, Type[Plugin]] = dict()
+PLUGIN_REGISTRY: Dict[str, Type[Plugin]] = dict()
+
+
+def register_plugin_category(category: str, expected_class: Type) -> None:
+    logger.debug(f"Registering plugin category {category}")
+    if category in PLUGIN_CATEGORY_REGISTRY:
+        raise TypeError(f"Plugin category {category} already registered")
+    if not issubclass(expected_class, Plugin):
+        raise TypeError(f"Plugin expected class for category should be a subclass of {Plugin} but was {expected_class}")
+    PLUGIN_CATEGORY_REGISTRY[category] = expected_class
+
+
+def _check_same_class(class_1, class_2) -> bool:
+    # To avoid raising "already registered" error when a certain plugin class is being reimported.
+    # Not a bullet proof check but should suffice for practical purposes.
+    return (
+        class_1.__name__ == class_2.__name__ and class_1.__module__.split(".")[-1] == class_2.__module__.split(".")[-1]
+    )
+
+
+def register_plugin(name: str, category: str):
+    def inner(cls: Type[Plugin]):
+        logger.debug(f"Registering plugin of class {cls}")
+        cls.name = name
+        cls.category = category
+
+        if cls.category not in PLUGIN_CATEGORY_REGISTRY:
+            raise TypeError(
+                f"Found plugin category {cls.category} which has not been registered with "
+                f"@{register_plugin_category.__name__}"
+            )
+        if not issubclass(cls, Plugin):
+            raise TypeError(f"Expected plugin class {cls.__name__} to be a subclass of {Plugin} but was {cls}")
+        if not issubclass(cls, PLUGIN_CATEGORY_REGISTRY[cls.category]):
+            raise TypeError(
+                f"Expected plugin class {cls.__name__} to be a subclass of "
+                f"{PLUGIN_CATEGORY_REGISTRY[cls.category]} but was {cls}"
+            )
+        if cls.fqn() in PLUGIN_REGISTRY:
+            if not _check_same_class(cls, PLUGIN_REGISTRY[cls.fqn()]):
+                raise TypeError(
+                    f"Plugin with fully-qualified name {cls.fqn()} already registered (as class "
+                    f"{PLUGIN_REGISTRY[cls.fqn()]})"
+                )
+            else:
+                # The same class is being reimported, do not raise error.
+                pass
+        PLUGIN_REGISTRY[cls.fqn()] = cls
+
+        @functools.wraps(cls)
+        def wrapper(*args, **kwargs):
+            return cls(*args, **kwargs)
+
+        # To access the class directly if required:
+        wrapper.cls = cls  # type: ignore
+
+        return wrapper
+
+    return inner
+
+
+class PluginLoader:
+    def __init__(self) -> None:
+        self._plugin_registry: Dict[str, Type[Plugin]] = PLUGIN_REGISTRY
+
+        name_by_category: Dict = dict()
+        for plugin_class in self._plugin_registry.values():
+            name_by_category = utils.append_by_dot_path(
+                name_by_category, key_path=plugin_class.category, value=plugin_class.name
+            )
+        self._plugin_name_by_category = name_by_category
+
+        class_by_category: Dict = dict()
+        for plugin_class in self._plugin_registry.values():
+            class_by_category = utils.append_by_dot_path(
+                class_by_category, key_path=plugin_class.category, value=plugin_class
+            )
+        self._plugin_class_by_category = class_by_category
+
+    def list(self) -> Dict:
+        return self._plugin_name_by_category
+
+    def list_fqns(self) -> List[str]:
+        return list(self._plugin_registry.keys())
+
+    def list_classes(self) -> Dict:
+        return self._plugin_class_by_category
+
+    def _raise_plugin_does_not_exist_error(self, name: str):
+        if name not in self._plugin_registry:
+            raise ValueError(f"Plugin {name} does not exist.")
+
+    def get(self, name: str, *args, **kwargs) -> Any:
+        self._raise_plugin_does_not_exist_error(name)
+        return self._plugin_registry[name](*args, **kwargs)
+
+    def get_class(self, name: str) -> Type:
+        self._raise_plugin_does_not_exist_error(name)
+        return self._plugin_registry[name]
+
+
+PLUGIN_FILENAME_PREFIX = "plugin_"
+
+
+def _glob_plugin_paths(package_dir: str) -> List[str]:
+    return [f for f in glob.glob(os.path.join(package_dir, f"{PLUGIN_FILENAME_PREFIX}*.py")) if os.path.isfile(f)]
+
+
+def _module_name_from_path(path: str) -> str:
+    return os.path.basename(path)[:-3]
+
+
+class importing:  # Functions as namespace, for clarity.
+    @staticmethod
+    def import_plugins(init_file: str) -> None:
+        package_dir = os.path.dirname(init_file)
+        logger.debug(f"Importing all plugin modules inside {package_dir}")
+        paths = _glob_plugin_paths(package_dir=package_dir)
+        logger.trace(f"Found plugin module paths to import:\n{paths}")
+        for f in paths:
+            module_name = _module_name_from_path(f)
+            logger.debug(f"Importing plugin module: {module_name}")
+            spec = importlib.util.spec_from_file_location(module_name, f)
+            if spec is None or not isinstance(spec.loader, importlib.abc.Loader):
+                raise RuntimeError(f"Import failed for {module_name}")
+            mod = importlib.util.module_from_spec(spec)
+            spec.loader.exec_module(mod)
+
+    @staticmethod
+    def gather_modules_names(package_init_file: str) -> List[str]:
+        package_dir = os.path.dirname(package_init_file)
+        paths = _glob_plugin_paths(package_dir=package_dir)
+        return [_module_name_from_path(f) for f in paths]
diff --git a/src/tempor/plugins/core/_types.py b/src/tempor/plugins/core/_types.py
new file mode 100644
index 00000000..83cb8f7b
--- /dev/null
+++ b/src/tempor/plugins/core/_types.py
@@ -0,0 +1,8 @@
+import enum
+
+
+class EstimatorMethods(enum.Enum):
+    FIT = enum.auto()
+    TRANSFORM = enum.auto()
+    PREDICT = enum.auto()
+    PREDICT_COUNTERFACTUAL = enum.auto()
diff --git a/src/tempor/plugins/core/utils.py b/src/tempor/plugins/core/utils.py
new file mode 100644
index 00000000..9cf8580e
--- /dev/null
+++ b/src/tempor/plugins/core/utils.py
@@ -0,0 +1,54 @@
+"""Module with utilities that support plugin functionality.
+"""
+
+from typing import Any, Dict, List
+
+# Helpers for organizing the plugin registry.
+
+
+def add_by_list_of_keys(dictionary: Dict, key_path: List[Any], value: Any) -> Dict:
+    """Add `value` to (nested) `dictionary` as specified by `key_path` (a list of nested keys)."""
+    key = key_path[0]
+    dictionary[key] = (
+        value
+        if len(key_path) == 1
+        else add_by_list_of_keys(
+            dictionary[key] if key in dictionary else dict(),
+            key_path[1:],
+            value,
+        )
+    )
+    return dictionary
+
+
+def get_by_list_of_keys(dictionary: Dict, key_path: List[Any]) -> Dict:
+    """Get value within (nested) `dictionary` as specified by `key_path` (a list of nested keys)."""
+    if len(key_path) == 1:
+        return dictionary[key_path[0]]
+    else:
+        return get_by_list_of_keys(dictionary[key_path[0]], key_path[1:])
+
+
+def add_by_dot_path(dictionary: Dict, key_path: str, value: Any) -> Dict:
+    """Add `value` to (nested) `dictionary` as specified by `key_path` (a dot separated string of nested keys)."""
+    return add_by_list_of_keys(dictionary, key_path.split("."), value)
+
+
+def get_by_dot_path(dictionary: Dict, key_path: str) -> Any:
+    """Get value within (nested) `dictionary` as specified by `key_path` (a dot separated string of nested keys)."""
+    return get_by_list_of_keys(dictionary, key_path.split("."))
+
+
+def append_by_dot_path(dictionary: Dict, key_path: str, value: Any) -> Dict:
+    """Append `value` to a list stored in a (nested) `dictionary` as specified by `key_path` (a dot separated string of
+    nested keys).
+
+    Expects item in `dictionary` at `key_path` to be a list, so that `value` can be appended.
+
+    If no `key_path` key found, create a list at `key_path` and put `value` into it.
+    """
+    try:
+        get_by_dot_path(dictionary, key_path).append(value)
+    except KeyError:
+        add_by_dot_path(dictionary, key_path, [value])
+    return dictionary
diff --git a/src/tempor/plugins/preprocessing/__init__.py b/src/tempor/plugins/preprocessing/__init__.py
new file mode 100644
index 00000000..ac7c4856
--- /dev/null
+++ b/src/tempor/plugins/preprocessing/__init__.py
@@ -0,0 +1,6 @@
+from . import imputation, scaling
+
+__all__ = [
+    "imputation",
+    "scaling",
+]
diff --git a/src/tempor/plugins/preprocessing/imputation/__init__.py b/src/tempor/plugins/preprocessing/imputation/__init__.py
new file mode 100644
index 00000000..bc958c27
--- /dev/null
+++ b/src/tempor/plugins/preprocessing/imputation/__init__.py
@@ -0,0 +1,16 @@
+import tempor.plugins.core as plugins
+
+
+class BaseImputer(plugins.BaseTransformer):
+    def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegation
+        super().__init__(**params)
+
+
+plugins.register_plugin_category("preprocessing.imputation", BaseImputer)
+
+plugins.importing.import_plugins(__file__)
+
+__all__ = [  # pyright: ignore
+    *plugins.importing.gather_modules_names(__file__),
+    "BaseImputer",
+]
diff --git a/src/tempor/plugins/preprocessing/imputation/plugin_nop.py b/src/tempor/plugins/preprocessing/imputation/plugin_nop.py
new file mode 100644
index 00000000..24909e41
--- /dev/null
+++ b/src/tempor/plugins/preprocessing/imputation/plugin_nop.py
@@ -0,0 +1,17 @@
+from typing import Any
+
+import tempor.plugins.core as plugins
+from tempor.data import dataset
+from tempor.plugins.preprocessing.imputation import BaseImputer
+
+
+@plugins.register_plugin(name="nop_imputer", category="preprocessing.imputation")
+class NopImputer(BaseImputer):
+    def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegation
+        super().__init__(**params)
+
+    def _fit(self, data: dataset.Dataset, *args, **kwargs) -> "NopImputer":
+        return self
+
+    def _transform(self, data: dataset.Dataset, *args, **kwargs) -> Any:
+        return data
diff --git a/src/tempor/plugins/preprocessing/scaling/__init__.py b/src/tempor/plugins/preprocessing/scaling/__init__.py
new file mode 100644
index 00000000..df5f15d5
--- /dev/null
+++ b/src/tempor/plugins/preprocessing/scaling/__init__.py
@@ -0,0 +1,16 @@
+import tempor.plugins.core as plugins
+
+
+class BaseScaler(plugins.BaseTransformer):
+    def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegation
+        super().__init__(**params)
+
+
+plugins.register_plugin_category("preprocessing.scaling", BaseScaler)
+
+plugins.importing.import_plugins(__file__)
+
+__all__ = [  # pyright: ignore
+    *plugins.importing.gather_modules_names(__file__),
+    "BaseScaler",
+]
diff --git a/src/tempor/plugins/preprocessing/scaling/plugin_nop.py b/src/tempor/plugins/preprocessing/scaling/plugin_nop.py
new file mode 100644
index 00000000..58210ab3
--- /dev/null
+++ b/src/tempor/plugins/preprocessing/scaling/plugin_nop.py
@@ -0,0 +1,17 @@
+from typing import Any
+
+import tempor.plugins.core as plugins
+from tempor.data import dataset
+from tempor.plugins.preprocessing.scaling import BaseScaler
+
+
+@plugins.register_plugin(name="nop_scaler", category="preprocessing.scaling")
+class NopScaler(BaseScaler):
+    def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegation
+        super().__init__(**params)
+
+    def _fit(self, data: dataset.Dataset, *args, **kwargs) -> "NopScaler":
+        return self
+
+    def _transform(self, data: dataset.Dataset, *args, **kwargs) -> Any:
+        return data
diff --git a/tutorials/tutorial01_data_format.ipynb b/tutorials/tutorial01_data_format.ipynb
new file mode 100644
index 00000000..8bf1aeda
--- /dev/null
+++ b/tutorials/tutorial01_data_format.ipynb
@@ -0,0 +1,585 @@
+{
+ "cells": [
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Tutorial 01: Data Format\n",
+    "\n",
+    "This tutorial shows a minimal example of the data format for TemporAI."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import pandas as pd\n",
+    "import numpy as np"
+   ]
+  },
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Time series data\n",
+    "\n",
+    "Time series data contains data samples (e.g. patients), with features that unfold sequentially over some number of timesteps.\n",
+    "\n",
+    "Time series data should take form of a `pandas.DataFrame`, with the following specifics:\n",
+    "* The index should be a 2-level multiindex, where level `0` index represents sampled IDs, and level `1` represents the timesteps for each sample.\n",
+    "* The sample index can be comprised of either `int`s or `str`s (homogenous, not a mix of these).\n",
+    "* The time index (timesteps) may be `int`, `float` or `pandas.Timestep`-compatible format (homogenous, not a mix of these).\n",
+    "* The columns of the dataframe represent the features, column names must be `str`.\n",
+    "* Column (feature) values currently supported are: `bool`, `int`, `float`, or `pandas.Categorical` (homogenous per column).\n",
+    "\n",
+    "Other points to note:\n",
+    "* Sample IDs must be unique.\n",
+    "* (Sample ID, timestep) combination must be unique (a sample cannot have more than one of the same timestep).\n",
+    "* Null values such as `numpy.nan` are allowed and represent missing values."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "<class 'pandas.core.frame.DataFrame'>\n",
+      "MultiIndex: 7 entries, ('sample_0', 1) to ('sample_2', 9)\n",
+      "Data columns (total 3 columns):\n",
+      " #   Column    Non-Null Count  Dtype   \n",
+      "---  ------    --------------  -----   \n",
+      " 0   t_feat_0  7 non-null      int64   \n",
+      " 1   t_feat_1  6 non-null      float64 \n",
+      " 2   t_feat_2  7 non-null      category\n",
+      "dtypes: category(1), float64(1), int64(1)\n",
+      "memory usage: 725.0+ bytes\n"
+     ]
+    },
+    {
+     "data": {
+      "text/html": [
+       "<div>\n",
+       "<style scoped>\n",
+       "    .dataframe tbody tr th:only-of-type {\n",
+       "        vertical-align: middle;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe tbody tr th {\n",
+       "        vertical-align: top;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe thead th {\n",
+       "        text-align: right;\n",
+       "    }\n",
+       "</style>\n",
+       "<table border=\"1\" class=\"dataframe\">\n",
+       "  <thead>\n",
+       "    <tr style=\"text-align: right;\">\n",
+       "      <th></th>\n",
+       "      <th></th>\n",
+       "      <th>t_feat_0</th>\n",
+       "      <th>t_feat_1</th>\n",
+       "      <th>t_feat_2</th>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>sample_idx</th>\n",
+       "      <th>time_idx</th>\n",
+       "      <th></th>\n",
+       "      <th></th>\n",
+       "      <th></th>\n",
+       "    </tr>\n",
+       "  </thead>\n",
+       "  <tbody>\n",
+       "    <tr>\n",
+       "      <th rowspan=\"4\" valign=\"top\">sample_0</th>\n",
+       "      <th>1</th>\n",
+       "      <td>11</td>\n",
+       "      <td>1.1</td>\n",
+       "      <td>a</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>2</th>\n",
+       "      <td>12</td>\n",
+       "      <td>1.2</td>\n",
+       "      <td>b</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>3</th>\n",
+       "      <td>13</td>\n",
+       "      <td>1.3</td>\n",
+       "      <td>b</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>4</th>\n",
+       "      <td>14</td>\n",
+       "      <td>NaN</td>\n",
+       "      <td>c</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th rowspan=\"2\" valign=\"top\">sample_1</th>\n",
+       "      <th>2</th>\n",
+       "      <td>21</td>\n",
+       "      <td>2.1</td>\n",
+       "      <td>a</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>4</th>\n",
+       "      <td>22</td>\n",
+       "      <td>2.2</td>\n",
+       "      <td>a</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>sample_2</th>\n",
+       "      <th>9</th>\n",
+       "      <td>31</td>\n",
+       "      <td>3.1</td>\n",
+       "      <td>c</td>\n",
+       "    </tr>\n",
+       "  </tbody>\n",
+       "</table>\n",
+       "</div>"
+      ],
+      "text/plain": [
+       "                     t_feat_0  t_feat_1 t_feat_2\n",
+       "sample_idx time_idx                             \n",
+       "sample_0   1               11       1.1        a\n",
+       "           2               12       1.2        b\n",
+       "           3               13       1.3        b\n",
+       "           4               14       NaN        c\n",
+       "sample_1   2               21       2.1        a\n",
+       "           4               22       2.2        a\n",
+       "sample_2   9               31       3.1        c"
+      ]
+     },
+     "execution_count": 2,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "# Create a time series dataframe.\n",
+    "\n",
+    "time_series_df = pd.DataFrame(\n",
+    "    {\n",
+    "        \"sample_idx\": [\"sample_0\", \"sample_0\", \"sample_0\", \"sample_0\", \"sample_1\", \"sample_1\", \"sample_2\"],\n",
+    "        \"time_idx\": [1, 2, 3, 4, 2, 4, 9],\n",
+    "        \"t_feat_0\": [11, 12, 13, 14, 21, 22, 31],\n",
+    "        \"t_feat_1\": [1.1, 1.2, 1.3, np.nan, 2.1, 2.2, 3.1],\n",
+    "        \"t_feat_2\": [\"a\", \"b\", \"b\", \"c\", \"a\", \"a\", \"c\"],\n",
+    "    }\n",
+    ")\n",
+    "\n",
+    "# Set the 2-level index:\n",
+    "time_series_df.set_index(keys=[\"sample_idx\", \"time_idx\"], drop=True, inplace=True)\n",
+    "\n",
+    "# \"feat_2\" needs to be set to a categorical, as `str` format is not supported.\n",
+    "time_series_df[\"t_feat_2\"] = pd.Categorical(time_series_df[\"t_feat_2\"])\n",
+    "\n",
+    "# Preview the dataframe:\n",
+    "time_series_df.info()\n",
+    "time_series_df"
+   ]
+  },
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Static data\n",
+    "\n",
+    "Static data contains data samples (e.g. patients), features that are not associated with a particular time.\n",
+    "\n",
+    "Static data should take form of a `pandas.DataFrame`, with the following specifics:\n",
+    "* The index represents sample IDs and is a (single level) index that can be comprised of `int`s or `str`s (homogenous, not a mix of these).\n",
+    "* The columns of the dataframe represent the features, column names must be `str`.\n",
+    "* Column (feature) values currently supported are: `bool`, `int`, `float`, or `pandas.Categorical` (homogenous per column).\n",
+    "\n",
+    "Other points to note:\n",
+    "* Sample IDs must be unique.\n",
+    "* Null values such as `numpy.nan` are allowed and represent missing values."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "<class 'pandas.core.frame.DataFrame'>\n",
+      "Index: 3 entries, sample_0 to sample_2\n",
+      "Data columns (total 2 columns):\n",
+      " #   Column    Non-Null Count  Dtype  \n",
+      "---  ------    --------------  -----  \n",
+      " 0   s_feat_0  3 non-null      int64  \n",
+      " 1   s_feat_1  2 non-null      float64\n",
+      "dtypes: float64(1), int64(1)\n",
+      "memory usage: 72.0+ bytes\n"
+     ]
+    },
+    {
+     "data": {
+      "text/html": [
+       "<div>\n",
+       "<style scoped>\n",
+       "    .dataframe tbody tr th:only-of-type {\n",
+       "        vertical-align: middle;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe tbody tr th {\n",
+       "        vertical-align: top;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe thead th {\n",
+       "        text-align: right;\n",
+       "    }\n",
+       "</style>\n",
+       "<table border=\"1\" class=\"dataframe\">\n",
+       "  <thead>\n",
+       "    <tr style=\"text-align: right;\">\n",
+       "      <th></th>\n",
+       "      <th>s_feat_0</th>\n",
+       "      <th>s_feat_1</th>\n",
+       "    </tr>\n",
+       "  </thead>\n",
+       "  <tbody>\n",
+       "    <tr>\n",
+       "      <th>sample_0</th>\n",
+       "      <td>100</td>\n",
+       "      <td>-1.1</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>sample_1</th>\n",
+       "      <td>200</td>\n",
+       "      <td>NaN</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>sample_2</th>\n",
+       "      <td>300</td>\n",
+       "      <td>-1.3</td>\n",
+       "    </tr>\n",
+       "  </tbody>\n",
+       "</table>\n",
+       "</div>"
+      ],
+      "text/plain": [
+       "          s_feat_0  s_feat_1\n",
+       "sample_0       100      -1.1\n",
+       "sample_1       200       NaN\n",
+       "sample_2       300      -1.3"
+      ]
+     },
+     "execution_count": 3,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "# Create a static data dataframe.\n",
+    "\n",
+    "static_df = pd.DataFrame(\n",
+    "    {\n",
+    "        \"s_feat_0\": [100, 200, 300],\n",
+    "        \"s_feat_1\": [-1.1, np.nan, -1.3],\n",
+    "    },\n",
+    "    index=[\"sample_0\", \"sample_1\", \"sample_2\"],\n",
+    ")\n",
+    "\n",
+    "# Preview the dataframe:\n",
+    "static_df.info()\n",
+    "static_df"
+   ]
+  },
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Event data\n",
+    "\n",
+    "Event data contains data samples (e.g. patients), with features that represent occurrence of an event at a certain time.\n",
+    "If the event did not occur, it is \"censored\".\n",
+    "\n",
+    "Event data should take form of a `pandas.DataFrame`, with the following specifics:\n",
+    "* The index represents sample IDs and is a (single level) index that can be comprised of `int`s or `str`s (homogenous, not a mix of these).\n",
+    "* The columns of the dataframe represent the features, column names must be `str`.\n",
+    "* Column (feature) values must be of the form: `Tuple[<timestep>, bool]`.\n",
+    "    * The first element `<timestep>` may be `int`, `float` or `pandas.Timestep`-compatible format (homogenous per column).\n",
+    "    * The second element `bool` indicates whether the event occurred at this time (`True`) or the event feature is censored (`False`).\n",
+    "    * In case of censoring, the timestep should indicate the last time information about the sample was available. \n",
+    "\n",
+    "Other points to note:\n",
+    "* Sample IDs must be unique.\n",
+    "* Null values such as `numpy.nan` are not allowed allowed - indicate an event as censored (did not occur) instead."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "<class 'pandas.core.frame.DataFrame'>\n",
+      "Index: 3 entries, sample_0 to sample_2\n",
+      "Data columns (total 2 columns):\n",
+      " #   Column    Non-Null Count  Dtype \n",
+      "---  ------    --------------  ----- \n",
+      " 0   e_feat_0  3 non-null      object\n",
+      " 1   e_feat_1  3 non-null      object\n",
+      "dtypes: object(2)\n",
+      "memory usage: 72.0+ bytes\n"
+     ]
+    },
+    {
+     "data": {
+      "text/html": [
+       "<div>\n",
+       "<style scoped>\n",
+       "    .dataframe tbody tr th:only-of-type {\n",
+       "        vertical-align: middle;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe tbody tr th {\n",
+       "        vertical-align: top;\n",
+       "    }\n",
+       "\n",
+       "    .dataframe thead th {\n",
+       "        text-align: right;\n",
+       "    }\n",
+       "</style>\n",
+       "<table border=\"1\" class=\"dataframe\">\n",
+       "  <thead>\n",
+       "    <tr style=\"text-align: right;\">\n",
+       "      <th></th>\n",
+       "      <th>e_feat_0</th>\n",
+       "      <th>e_feat_1</th>\n",
+       "    </tr>\n",
+       "  </thead>\n",
+       "  <tbody>\n",
+       "    <tr>\n",
+       "      <th>sample_0</th>\n",
+       "      <td>(10, True)</td>\n",
+       "      <td>(10, False)</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>sample_1</th>\n",
+       "      <td>(12, False)</td>\n",
+       "      <td>(10, False)</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>sample_2</th>\n",
+       "      <td>(13, True)</td>\n",
+       "      <td>(11, True)</td>\n",
+       "    </tr>\n",
+       "  </tbody>\n",
+       "</table>\n",
+       "</div>"
+      ],
+      "text/plain": [
+       "             e_feat_0     e_feat_1\n",
+       "sample_0   (10, True)  (10, False)\n",
+       "sample_1  (12, False)  (10, False)\n",
+       "sample_2   (13, True)   (11, True)"
+      ]
+     },
+     "execution_count": 4,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "# Create an event dataframe.\n",
+    "\n",
+    "event_df = pd.DataFrame(\n",
+    "    {\n",
+    "        \"e_feat_0\": [(10, True), (12, False), (13, True)],\n",
+    "        \"e_feat_1\": [(10, False), (10, False), (11, True)],\n",
+    "    },\n",
+    "    index=[\"sample_0\", \"sample_1\", \"sample_2\"],\n",
+    ")\n",
+    "\n",
+    "# Preview the dataframe:\n",
+    "event_df.info()\n",
+    "event_df"
+   ]
+  },
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The data can also be initialised from a 2D `numpy` array (static, event) or a 3D `numpy` array (time series).\n",
+    "\n",
+    "`TODO: more info`"
+   ]
+  },
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Dataset\n",
+    "\n",
+    "The collection of data that represents the task at hand is a `Dataset`.\n",
+    "\n",
+    "A `Dataset` contains:\n",
+    "* Time series data (covariates),\n",
+    "* Static data (covariates), optional,\n",
+    "* Predictive data, which depends on the *task*.\n",
+    "    * This may contain *targets* and *treatments*.\n",
+    "\n",
+    "For example, for the *time-to-event analysis task* we create a dataset as follows.\n",
+    "\n",
+    "`TODO: more info`"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 5,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "TimeToEventAnalysisDataset(\n",
+       "    time_series=TimeSeriesSamples([3, *, 3]),\n",
+       "    static=StaticSamples([3, 2]),\n",
+       "    static=TimeToEventAnalysisTaskData(targets=EventSamples([3, 2]))\n",
+       ")"
+      ]
+     },
+     "execution_count": 5,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "from tempor.data.dataset import TimeToEventAnalysisDataset\n",
+    "\n",
+    "# Create a dataset of time-to-event analysis task:\n",
+    "data = TimeToEventAnalysisDataset(\n",
+    "    time_series=time_series_df,\n",
+    "    static=static_df,\n",
+    "    targets=event_df,\n",
+    ")\n",
+    "\n",
+    "# Preview dataset:\n",
+    "data"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 6,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "TimeSeriesSamples with data:\n",
+       "                     t_feat_0  t_feat_1 t_feat_2\n",
+       "sample_idx time_idx                             \n",
+       "sample_0   1               11       1.1        a\n",
+       "           2               12       1.2        b\n",
+       "           3               13       1.3        b\n",
+       "           4               14       NaN        c\n",
+       "sample_1   2               21       2.1        a\n",
+       "           4               22       2.2        a\n",
+       "sample_2   9               31       3.1        c"
+      ]
+     },
+     "execution_count": 6,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "data.time_series"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 7,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "StaticSamples with data:\n",
+       "            s_feat_0  s_feat_1\n",
+       "sample_idx                    \n",
+       "sample_0         100      -1.1\n",
+       "sample_1         200       NaN\n",
+       "sample_2         300      -1.3"
+      ]
+     },
+     "execution_count": 7,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "data.static"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 8,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "EventSamples with data:\n",
+       "               e_feat_0     e_feat_1\n",
+       "sample_idx                          \n",
+       "sample_0     (10, True)  (10, False)\n",
+       "sample_1    (12, False)  (10, False)\n",
+       "sample_2     (13, True)   (11, True)"
+      ]
+     },
+     "execution_count": 8,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "data.predictive.targets"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "RE_py38_temporai",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.8.15"
+  },
+  "orig_nbformat": 4
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}


Test patch:
diff --git a/tests/config/test_config.py b/tests/config/test_config.py
index c92ee111..6b0cb70f 100644
--- a/tests/config/test_config.py
+++ b/tests/config/test_config.py
@@ -19,14 +19,14 @@ def mock_updated_on_configure(monkeypatch):
 def test_updated_on_configure(monkeypatch):
     config = tempor.get_config()
     assert isinstance(config, TemporConfig)
-    assert config.logging.level == "DEBUG"
+    assert config.logging.level == "INFO"
     assert config == tempor.config.get_config()
 
 
 def test_default_config(mock_updated_on_configure):
     config = tempor.get_config()
     assert isinstance(config, TemporConfig)
-    assert config.logging.level == "DEBUG"
+    assert config.logging.level == "INFO"
     assert config == tempor.config.get_config()
 
 
diff --git a/tests/conftest.py b/tests/conftest.py
index f8302424..0faadca7 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,10 +1,79 @@
-"""
-    Dummy conftest.py for tempor.
+import copy
+from types import ModuleType
+from typing import Any, List, Tuple
 
-    If you don't know what this is for, just leave it empty.
-    Read more about conftest.py under:
-    - https://docs.pytest.org/en/stable/fixture.html
-    - https://docs.pytest.org/en/stable/writing_plugins.html
-"""
+import pytest
 
-# import pytest
+# --- Test utilities. ---
+
+
+@pytest.fixture(scope="function")
+def patch_module(monkeypatch, request):
+    """A test utility to reload a module under test, patching certain module-level variables
+    and optionally refresh pydantic.
+
+    For reference:
+    - https://github.com/streamlit/streamlit/issues/3218#issuecomment-1050647471
+    - https://docs.pytest.org/en/6.2.x/fixture.html#adding-finalizers-directly
+
+    Args:
+        monkeypatch: pytest monkeypatch object.
+        request: pytest request object.
+
+    Returns:
+        Callable: configuration function whose parameters should be set downstream.
+    """
+
+    def _patch_module(
+        main_module: ModuleType,
+        module_vars: List[
+            Tuple[
+                ModuleType,  # Module of variable.
+                Any,  # Variable value.
+                str,  # Name of variable.
+                Any,  # Patch value.
+            ]
+        ],
+        refresh_pydantic: bool = False,
+    ):
+        """Configuration function for patching a module.
+
+        Args:
+            main_module (ModuleType): The module which will be reloaded after patching.
+            module_vars (List[Tuple[ModuleType, Any, str, Any]): A list of tuples specifying which module-level
+            variables to patch. Each tuple is of form `(<module of variable>, <variable>, <name of variable>,
+            <patch value>)`. The variables to patch may not be in `main_module` directly but rather in modules that
+            `main_modules` imports.
+            refresh_pydantic (bool, optional): Refresh pydantic (e.g. class validators to avoid duplicate registration
+            of validators). Defaults to False.
+        """
+        undo = dict()
+        for module, var, var_name, patch_value in module_vars:
+            undo[var_name] = copy.deepcopy(var)
+            monkeypatch.setattr(module, var_name, patch_value)
+
+        if refresh_pydantic:
+            import pydantic
+
+            pydantic.class_validators._FUNCS.clear()  # pylint: disable=protected-access
+
+        import importlib
+
+        importlib.reload(main_module)
+
+        def teardown():
+            """Teardown: reset the module variables at the teardown of this fixture, so that patching doesn't affect
+            other tests.
+            """
+
+            for module, _, var_name, _ in module_vars:
+                setattr(module, var_name, undo[var_name])
+
+            if refresh_pydantic:
+                pydantic.class_validators._FUNCS.clear()  # pylint: disable=protected-access  # pyright: ignore
+
+            importlib.reload(main_module)
+
+        request.addfinalizer(teardown)
+
+    return _patch_module
diff --git a/tests/core/test_requirements.py b/tests/core/test_requirements.py
new file mode 100644
index 00000000..9c5c2a31
--- /dev/null
+++ b/tests/core/test_requirements.py
@@ -0,0 +1,153 @@
+from unittest.mock import Mock
+
+import pydantic
+import pytest
+
+from tempor.core import requirements as r
+
+MyReqCategory = Mock()
+
+
+def test_init_success():
+    class MyRequirement(r.Requirement):
+        name = "my_requirement"
+
+        @property
+        def requirement_category(self):
+            return MyReqCategory
+
+    _ = MyRequirement(definition="some value")
+
+
+def test_init_fails_no_name():
+    class MyRequirement(r.Requirement):
+        @property
+        def requirement_category(self):
+            return MyReqCategory
+
+    with pytest.raises(ValueError, match=".*name.*"):
+        _ = MyRequirement(definition="some value")
+
+
+def test_init_definition_type_success():
+    class MyRequirement(r.Requirement):
+        name = "my_requirement"
+        definition: float
+
+        @property
+        def requirement_category(self):
+            return MyReqCategory
+
+    _ = MyRequirement(definition=1.5)
+
+
+def test_init_definition_type_fails():
+    class MyRequirement(r.Requirement):
+        name = "my_requirement"
+        definition: float
+
+        @property
+        def requirement_category(self):
+            return MyReqCategory
+
+    with pytest.raises(ValueError, match=".*definition.*"):
+        _ = MyRequirement(definition="incompatible")
+
+
+def test_init_extra_validator_success():
+    mock_call = Mock()
+
+    @pydantic.validator("definition")
+    def extra_validator(cls, v):  # pylint: disable=unused-argument
+        mock_call(value=v)
+        return v
+
+    class MyRequirement(r.Requirement):
+        name = "my_requirement"
+        definition: float
+        validator_methods = [extra_validator]
+
+        @property
+        def requirement_category(self):
+            return MyReqCategory
+
+    _ = MyRequirement(definition=1.5)
+
+    mock_call.assert_called_once_with(value=1.5)
+
+
+def test_init_extra_validator_fail():
+    @pydantic.validator("definition")
+    def extra_validator(cls, v):  # pylint: disable=unused-argument
+        if v != "foo":
+            raise ValueError("Not foo")
+        return v
+
+    class MyRequirement(r.Requirement):
+        name = "my_requirement"
+        definition: str
+        validator_methods = [extra_validator]
+
+        @property
+        def requirement_category(self):
+            return MyReqCategory
+
+    with pytest.raises(ValueError, match=".*Not foo.*"):
+        _ = MyRequirement(definition="bar")
+
+
+def test_validator_validate_success():
+    mock_validate = Mock()
+    mock_target = Mock()
+
+    class MyValidator(r.RequirementValidator):
+        @property
+        def supported_requirement_category(self):
+            return MyReqCategory
+
+        def _validate(self, *args, **kwargs):
+            mock_validate(*args, **kwargs)
+
+    class MyRequirement(r.Requirement):
+        name = "my_requirement"
+
+        @property
+        def requirement_category(self):
+            return MyReqCategory
+
+    requirements = (MyRequirement("A"), MyRequirement("B"))
+    validator = MyValidator()
+
+    validator.validate(target=mock_target, requirements=requirements)
+
+    mock_validate.assert_called_once_with(target=mock_target, requirements=requirements)
+
+
+def test_validator_validate_fails_not_supported_category():
+    mock_validate = Mock()
+    mock_target = Mock()
+
+    MyOtherCategory = Mock()
+
+    class MyValidator(r.RequirementValidator):
+        @property
+        def supported_requirement_category(self):
+            return MyReqCategory
+
+        def _validate(self, *args, **kwargs):
+            mock_validate(*args, **kwargs)
+
+    class MyOtherRequirement(r.Requirement):
+        name = "my_requirement"
+
+        @property
+        def requirement_category(self):
+            return MyOtherCategory
+
+    requirements = (MyOtherRequirement("A"), MyOtherRequirement("B"))
+    validator = MyValidator()
+
+    with pytest.raises(ValueError):
+        validator.validate(target=mock_target, requirements=requirements)
+
+    mock_validate.assert_not_called()
diff --git a/tests/data/conftest.py b/tests/data/conftest.py
deleted file mode 100644
index ab367fa6..00000000
--- a/tests/data/conftest.py
+++ /dev/null
@@ -1,51 +0,0 @@
-import numpy as np
-import pandas as pd
-import pytest
-
-# Contains common fixtures.
-
-
-@pytest.fixture
-def df_static_cat_num_hasnan():
-    # Has categorical and float columns, has null values.
-    categories = ["A", "B", "C"]
-    np.random.seed(12345)
-    size = 10
-    df = pd.DataFrame(
-        {
-            "cat_var_1": pd.Categorical(np.random.choice(categories, size=size)),
-            "cat_var_2": pd.Categorical(np.random.choice(categories, size=size)),
-            "num_var_1": np.random.uniform(0, 10, size=size),
-            "num_var_2": np.random.uniform(20, 30, size=size),
-        }
-    )
-    df.loc[0, "num_var_1"] = np.nan
-    return df
-
-
-@pytest.fixture
-def df_time_series_num_nonan():
-    df = pd.DataFrame(
-        {
-            "sample_idx": ["a", "a", "a", "a", "b", "b", "c"],
-            "time_idx": [1, 2, 3, 4, 2, 4, 9],
-            "f1": [11, 12, 13, 14, 21, 22, 31],
-            "f2": [1.1, 1.2, 1.3, 1.4, 2.1, 2.2, 3.1],
-        }
-    )
-    df = df.set_index(keys=["sample_idx", "time_idx"])
-    return df
-
-
-@pytest.fixture
-def df_event_num_nonan():
-    df = pd.DataFrame(
-        {
-            "sample_idx": ["a", "b", "c"],
-            "time_idx": [1, 2, 2],
-            "f1": [True, False, True],
-            "f2": [0, 0, 1],
-        }
-    )
-    df = df.set_index(keys=["sample_idx", "time_idx"])
-    return df
diff --git a/tests/data/test_check_data_container_def.py b/tests/data/test_check_data_container_def.py
deleted file mode 100644
index f83bdb17..00000000
--- a/tests/data/test_check_data_container_def.py
+++ /dev/null
@@ -1,252 +0,0 @@
-# pylint: disable=redefined-outer-name, unused-argument
-
-import copy
-import enum
-from typing import Union
-
-import pydantic
-import pytest
-
-import tempor.data._settings as settings
-import tempor.data._types as types
-from tempor.data import _check_data_container_def as check
-
-
-class DummyDataCategory(enum.Enum):
-    X = enum.auto()
-    Y = enum.auto()
-
-
-class DummyDataContainerA:
-    pass
-
-
-class DummyDataContainerB:
-    pass
-
-
-class DummyDataContainerC:
-    pass
-
-
-class DummyContainerFlavor(enum.Enum):
-    CAT_X_CONT_A_1 = enum.auto()
-    CAT_X_CONT_A_2 = enum.auto()
-    CAT_X_CONT_B = enum.auto()
-    CAT_Y_CONT_B_1 = enum.auto()
-    CAT_Y_CONT_B_2 = enum.auto()
-
-
-DUMMY_DATA_CATEGORY_TO_CONTAINER_FLAVORS = {
-    DummyDataCategory.X: {
-        DummyContainerFlavor.CAT_X_CONT_A_1,
-        DummyContainerFlavor.CAT_X_CONT_A_2,
-        DummyContainerFlavor.CAT_X_CONT_B,
-    },
-    DummyDataCategory.Y: {
-        DummyContainerFlavor.CAT_Y_CONT_B_1,
-        DummyContainerFlavor.CAT_Y_CONT_B_2,
-    },
-}
-
-
-DUMMY_CONTAINER_CLASS_TO_CONTAINER_FLAVORS = {
-    DummyDataContainerA: {
-        DummyContainerFlavor.CAT_X_CONT_A_1,
-        DummyContainerFlavor.CAT_X_CONT_A_2,
-    },
-    DummyDataContainerB: {
-        DummyContainerFlavor.CAT_X_CONT_B,
-        DummyContainerFlavor.CAT_Y_CONT_B_1,
-        DummyContainerFlavor.CAT_Y_CONT_B_2,
-    },
-}
-
-DUMMY_DEFAULT_CONTAINER_FLAVOR = {
-    (DummyDataCategory.X, DummyDataContainerA): DummyContainerFlavor.CAT_X_CONT_A_1,
-    (DummyDataCategory.X, DummyDataContainerB): DummyContainerFlavor.CAT_X_CONT_B,
-    (DummyDataCategory.Y, DummyDataContainerB): DummyContainerFlavor.CAT_Y_CONT_B_1,
-}
-
-
-@pytest.fixture
-def patch_module(monkeypatch):
-    # Reload the module under test while also refreshing aspects of pydantic as needed.
-    # See: https://github.com/streamlit/streamlit/issues/3218#issuecomment-1050647471
-
-    undo = dict()
-    undo["DataContainer"] = copy.deepcopy(types.DataContainer)
-    undo["DataCategory"] = copy.deepcopy(types.DataCategory)
-    undo["ContainerFlavor"] = copy.deepcopy(types.ContainerFlavor)
-    undo["DATA_CATEGORY_TO_CONTAINER_FLAVORS"] = copy.deepcopy(settings.DATA_CATEGORY_TO_CONTAINER_FLAVORS)
-    undo["CONTAINER_CLASS_TO_CONTAINER_FLAVORS"] = copy.deepcopy(settings.CONTAINER_CLASS_TO_CONTAINER_FLAVORS)
-    undo["DEFAULT_CONTAINER_FLAVOR"] = copy.deepcopy(settings.DEFAULT_CONTAINER_FLAVOR)
-    monkeypatch.setattr(types, "DataContainer", Union[DummyDataContainerA, DummyDataContainerB])
-    monkeypatch.setattr(types, "DataCategory", DummyDataCategory)
-    monkeypatch.setattr(types, "ContainerFlavor", DummyContainerFlavor)
-    monkeypatch.setattr(settings, "DATA_CATEGORY_TO_CONTAINER_FLAVORS", DUMMY_DATA_CATEGORY_TO_CONTAINER_FLAVORS)
-    monkeypatch.setattr(settings, "CONTAINER_CLASS_TO_CONTAINER_FLAVORS", DUMMY_CONTAINER_CLASS_TO_CONTAINER_FLAVORS)
-    monkeypatch.setattr(settings, "DEFAULT_CONTAINER_FLAVOR", DUMMY_DEFAULT_CONTAINER_FLAVOR)
-
-    pydantic.class_validators._FUNCS.clear()  # pylint: disable=protected-access
-    import importlib
-
-    importlib.reload(check)
-
-    yield
-
-    # Teardown - must manually undo the monkeypatching to what it should be, for other tests to run correctly:
-
-    types.DataContainer = undo["DataContainer"]
-    types.DataCategory = undo["DataCategory"]
-    types.ContainerFlavor = undo["ContainerFlavor"]
-    settings.DATA_CATEGORY_TO_CONTAINER_FLAVORS = undo["DATA_CATEGORY_TO_CONTAINER_FLAVORS"]
-    settings.CONTAINER_CLASS_TO_CONTAINER_FLAVORS = undo["CONTAINER_CLASS_TO_CONTAINER_FLAVORS"]
-    settings.DEFAULT_CONTAINER_FLAVOR = undo["DEFAULT_CONTAINER_FLAVOR"]
-
-    pydantic.class_validators._FUNCS.clear()  # pylint: disable=protected-access
-    importlib.reload(check)
-
-
-@pytest.mark.parametrize(
-    "passing_combination",
-    [
-        {
-            "data_category": DummyDataCategory.X,
-            "container_class": DummyDataContainerA,
-            "container_flavor": DummyContainerFlavor.CAT_X_CONT_A_1,
-        },
-        {
-            "data_category": DummyDataCategory.X,
-            "container_class": DummyDataContainerA,
-            "container_flavor": DummyContainerFlavor.CAT_X_CONT_A_2,
-        },
-        {
-            "data_category": DummyDataCategory.X,
-            "container_class": DummyDataContainerB,
-            "container_flavor": DummyContainerFlavor.CAT_X_CONT_B,
-        },
-        {
-            "data_category": DummyDataCategory.Y,
-            "container_class": DummyDataContainerB,
-            "container_flavor": DummyContainerFlavor.CAT_Y_CONT_B_1,
-        },
-        {
-            "data_category": DummyDataCategory.Y,
-            "container_class": DummyDataContainerB,
-            "container_flavor": DummyContainerFlavor.CAT_Y_CONT_B_2,
-        },
-    ],
-)
-def test_init_success(patch_module, passing_combination):
-    check.CheckDataContainerDefinition(
-        data_category=passing_combination["data_category"],
-        container_class=passing_combination["container_class"],
-        container_flavor=passing_combination["container_flavor"],
-    )
-
-
-def test_init_fails_wrong_container_class(patch_module):
-    with pytest.raises(pydantic.ValidationError, match=".*container_class.*"):
-        check.CheckDataContainerDefinition(
-            data_category=DummyDataCategory.X,  # pyright: ignore
-            container_class=DummyDataContainerC,  # pyright: ignore
-            container_flavor=DummyContainerFlavor.CAT_X_CONT_A_1,  # pyright: ignore
-        )
-
-
-@pytest.mark.parametrize(
-    "failing_combination",
-    [
-        {
-            "data_category": DummyDataCategory.X,
-            "container_class": DummyDataContainerB,
-            "container_flavor": DummyContainerFlavor.CAT_Y_CONT_B_1,
-        },
-        {
-            "data_category": DummyDataCategory.X,
-            "container_class": DummyDataContainerB,
-            "container_flavor": DummyContainerFlavor.CAT_Y_CONT_B_2,
-        },
-        {
-            "data_category": DummyDataCategory.Y,
-            "container_class": DummyDataContainerA,
-            "container_flavor": DummyContainerFlavor.CAT_X_CONT_A_1,
-        },
-        {
-            "data_category": DummyDataCategory.Y,
-            "container_class": DummyDataContainerA,
-            "container_flavor": DummyContainerFlavor.CAT_X_CONT_A_2,
-        },
-        {
-            "data_category": DummyDataCategory.Y,
-            "container_class": DummyDataContainerB,
-            "container_flavor": DummyContainerFlavor.CAT_X_CONT_B,
-        },
-    ],
-)
-def test_init_fails_wrong_combination_data_category(patch_module, failing_combination):
-    with pytest.raises(pydantic.ValidationError, match=".*category.*"):
-        check.CheckDataContainerDefinition(
-            data_category=failing_combination["data_category"],
-            container_class=failing_combination["container_class"],
-            container_flavor=failing_combination["container_flavor"],
-        )
-
-
-@pytest.mark.parametrize(
-    "failing_combination",
-    [
-        {
-            "data_category": DummyDataCategory.X,
-            "container_class": DummyDataContainerB,
-            "container_flavor": DummyContainerFlavor.CAT_X_CONT_A_1,
-        },
-        {
-            "data_category": DummyDataCategory.X,
-            "container_class": DummyDataContainerB,
-            "container_flavor": DummyContainerFlavor.CAT_X_CONT_A_2,
-        },
-        {
-            "data_category": DummyDataCategory.X,
-            "container_class": DummyDataContainerA,
-            "container_flavor": DummyContainerFlavor.CAT_X_CONT_B,
-        },
-        {
-            "data_category": DummyDataCategory.Y,
-            "container_class": DummyDataContainerA,
-            "container_flavor": DummyContainerFlavor.CAT_Y_CONT_B_1,
-        },
-        {
-            "data_category": DummyDataCategory.Y,
-            "container_class": DummyDataContainerA,
-            "container_flavor": DummyContainerFlavor.CAT_Y_CONT_B_2,
-        },
-    ],
-)
-def test_init_fails_wrong_combination_data_container(patch_module, failing_combination):
-    with pytest.raises(pydantic.ValidationError, match=".*container.*"):
-        check.CheckDataContainerDefinition(
-            data_category=failing_combination["data_category"],
-            container_class=failing_combination["container_class"],
-            container_flavor=failing_combination["container_flavor"],
-        )
-
-
-def test_init_fails_wrong_combination_data_container_data_category(patch_module):
-    with pytest.raises(pydantic.ValidationError, match=".*category.*container.*"):
-        check.CheckDataContainerDefinition(
-            data_category=DummyDataCategory.Y,  # pyright: ignore
-            container_class=DummyDataContainerB,  # pyright: ignore
-            container_flavor=DummyContainerFlavor.CAT_X_CONT_A_1,  # pyright: ignore
-        )
-
-
-def test_init_fails_no_default(patch_module):
-    del DUMMY_DEFAULT_CONTAINER_FLAVOR[(DummyDataCategory.X, DummyDataContainerA)]
-    with pytest.raises(pydantic.ValidationError, match=".*default.*"):
-        check.CheckDataContainerDefinition(
-            data_category=DummyDataCategory.X,  # pyright: ignore
-            container_class=DummyDataContainerA,  # pyright: ignore
-            container_flavor=DummyContainerFlavor.CAT_X_CONT_A_1,  # pyright: ignore
-        )
diff --git a/tests/data/test_dataset.py b/tests/data/test_dataset.py
new file mode 100644
index 00000000..38438b5f
--- /dev/null
+++ b/tests/data/test_dataset.py
@@ -0,0 +1,732 @@
+# pylint: disable=redefined-outer-name, unused-argument, protected-access
+
+import dataclasses
+from typing import Type
+from unittest.mock import Mock
+
+import pytest
+
+from tempor.data import dataset, predictive, samples
+
+
+@dataclasses.dataclass(frozen=True)
+class MockedDepends:
+    StaticSamples: Mock
+    TimeSeriesSamples: Mock
+    EventSamples: Mock
+
+    # predictive:
+    OneOffPredictionTaskData: Mock
+    TemporalPredictionTaskData: Mock
+    TimeToEventAnalysisTaskData: Mock
+    OneOffTreatmentEffectsTaskData: Mock
+    TemporalTreatmentEffectsTaskData: Mock
+
+
+@pytest.fixture
+def mocked_depends(monkeypatch) -> MockedDepends:
+    MockStaticSamples = Mock()
+    MockTimeSeriesSamples = Mock()
+    MockEventSamples = Mock()
+    monkeypatch.setattr(samples, "StaticSamples", MockStaticSamples)
+    monkeypatch.setattr(samples, "TimeSeriesSamples", MockTimeSeriesSamples)
+    monkeypatch.setattr(samples, "EventSamples", MockEventSamples)
+
+    MockOneOffPredictionTaskData = Mock()
+    MockTemporalPredictionTaskData = Mock()
+    MockTimeToEventAnalysisTaskData = Mock()
+    MockOneOffTreatmentEffectsTaskData = Mock()
+    MockTemporalTreatmentEffectsTaskData = Mock()
+    monkeypatch.setattr(predictive, "OneOffPredictionTaskData", MockOneOffPredictionTaskData)
+    monkeypatch.setattr(predictive, "TemporalPredictionTaskData", MockTemporalPredictionTaskData)
+    monkeypatch.setattr(predictive, "TimeToEventAnalysisTaskData", MockTimeToEventAnalysisTaskData)
+    monkeypatch.setattr(predictive, "OneOffTreatmentEffectsTaskData", MockOneOffTreatmentEffectsTaskData)
+    monkeypatch.setattr(predictive, "TemporalTreatmentEffectsTaskData", MockTemporalTreatmentEffectsTaskData)
+
+    return MockedDepends(
+        StaticSamples=MockStaticSamples,
+        TimeSeriesSamples=MockTimeSeriesSamples,
+        EventSamples=MockEventSamples,
+        # predictive:
+        OneOffPredictionTaskData=MockOneOffPredictionTaskData,
+        TemporalPredictionTaskData=MockTemporalPredictionTaskData,
+        TimeToEventAnalysisTaskData=MockTimeToEventAnalysisTaskData,
+        OneOffTreatmentEffectsTaskData=MockOneOffTreatmentEffectsTaskData,
+        TemporalTreatmentEffectsTaskData=MockTemporalTreatmentEffectsTaskData,
+    )
+
+
+def mock_sample_index(monkeypatch, cls_name, sample_index):
+    instance = Mock()
+    instance.sample_index = Mock(return_value=sample_index)
+    monkeypatch.setattr(samples, cls_name, Mock(return_value=instance))
+
+
+def mock_predictive_data_sample_index(monkeypatch, cls_name, targets_sample_index, treatments_sample_index):
+    targets_instance = Mock()
+    targets_instance.sample_index = Mock(return_value=targets_sample_index)
+    treatments_instance = Mock()
+    treatments_instance.sample_index = Mock(return_value=treatments_sample_index)
+    MockOneOffPredictionTaskData = Mock(return_value=Mock(targets=targets_instance, treatments=treatments_instance))
+    monkeypatch.setattr(predictive, cls_name, MockOneOffPredictionTaskData)
+
+
+# --- Dataset base class. ---
+
+
+class MockedDataset(dataset.Dataset):
+    mock_validate_call: Mock
+    mock_init_predictive: Mock
+
+    def _init_predictive(self, targets, treatments=None, **kwargs) -> None:
+        self.predictive = self.mock_init_predictive(targets=targets, treatments=treatments, **kwargs)
+        self.predictive.predictive_task = "dummy_task"
+
+    def validate(self) -> None:
+        self._validate()
+
+    def _validate(self) -> None:
+        self.mock_validate_call()
+
+
+@pytest.fixture
+def mock_dataset_cls():
+    MockedDataset.mock_validate_call = Mock()
+    MockedDataset.mock_init_predictive = Mock()
+    return MockedDataset
+
+
+class TestDataset:
+    def test_init(self, mock_dataset_cls: Type[MockedDataset], mocked_depends: MockedDepends):
+        data_time_series = Mock()
+        data_static = Mock()
+        data_targets = Mock()
+        data_treatments = Mock()
+
+        dummy_dataset = mock_dataset_cls(
+            time_series=data_time_series,
+            static=data_static,
+            targets=data_targets,
+            treatments=data_treatments,
+            **{"dummy": "kwarg"},
+        )
+
+        mocked_depends.TimeSeriesSamples.assert_called_once_with(data_time_series)
+        mocked_depends.StaticSamples.assert_called_once_with(data_static)
+        dummy_dataset.mock_init_predictive.assert_called_once_with(
+            targets=data_targets, treatments=data_treatments, dummy="kwarg"
+        )
+        dummy_dataset.mock_validate_call.assert_called_once()
+
+    def test_init_no_targets_provided(self, mock_dataset_cls: Type[MockedDataset], mocked_depends: MockedDepends):
+        data_time_series = Mock()
+        data_static = Mock()
+        data_targets = None
+
+        dummy_dataset = mock_dataset_cls(
+            time_series=data_time_series,
+            static=data_static,
+            targets=data_targets,
+        )
+
+        mocked_depends.TimeSeriesSamples.assert_called_once_with(data_time_series)
+        mocked_depends.StaticSamples.assert_called_once_with(data_static)
+        dummy_dataset.mock_init_predictive.assert_not_called()
+        dummy_dataset.mock_validate_call.assert_called_once()
+
+    @pytest.mark.parametrize(
+        "static, expected",
+        [
+            (Mock(), True),
+            (None, False),
+        ],
+    )
+    def test_has_static(
+        self, static, expected: bool, mock_dataset_cls: Type[MockedDataset], mocked_depends: MockedDepends
+    ):
+        data_time_series = Mock()
+        data_targets = None
+
+        dummy_dataset = mock_dataset_cls(
+            time_series=data_time_series,
+            static=static,
+            targets=data_targets,
+        )
+
+        assert dummy_dataset.has_static is expected
+
+    @pytest.mark.parametrize(
+        "targets, expected_has_predictive_data, expected_predictive_task",
+        [
+            (Mock(), True, "dummy_task"),
+            (None, False, None),
+        ],
+    )
+    def test_has_predictive_data_and_predictive_task(
+        self,
+        targets,
+        expected_has_predictive_data: bool,
+        expected_predictive_task,
+        mock_dataset_cls: Type[MockedDataset],
+        mocked_depends: MockedDepends,
+    ):
+        data_time_series = Mock()
+        data_static = Mock()
+
+        dummy_dataset = mock_dataset_cls(
+            time_series=data_time_series,
+            static=data_static,
+            targets=targets,
+        )
+
+        assert dummy_dataset.has_predictive_data is expected_has_predictive_data
+        assert dummy_dataset.predictive_task is expected_predictive_task
+
+    @pytest.mark.parametrize("static", [Mock(), None])
+    def test_validate_passes(self, static, mock_dataset_cls: Type[MockedDataset], monkeypatch):
+        data_time_series = Mock()
+        mock_sample_index(monkeypatch, cls_name="StaticSamples", sample_index=["s1", "s2"])
+        mock_sample_index(monkeypatch, cls_name="TimeSeriesSamples", sample_index=["s1", "s2"])
+
+        mock_dataset_cls.validate = dataset.Dataset.validate  # type: ignore  # Enable validation code.
+
+        dummy_dataset = mock_dataset_cls(
+            time_series=data_time_series,
+            static=static,
+            targets=None,
+        )
+
+        dummy_dataset.mock_validate_call.assert_called_once()
+
+    def test_validate_fails_sample_index_mismatch(self, mock_dataset_cls: Type[MockedDataset], monkeypatch):
+        data_time_series = Mock()
+        data_static = Mock()
+        mock_sample_index(monkeypatch, cls_name="StaticSamples", sample_index=["s1", "s2"])
+        mock_sample_index(monkeypatch, cls_name="TimeSeriesSamples", sample_index=["s1", "s2", "s3"])
+
+        mock_dataset_cls.validate = dataset.Dataset.validate  # type: ignore  # Enable validation code.
+
+        with pytest.raises(ValueError, match=".*sample_index.*static.*time series.*"):
+            mock_dataset_cls(
+                time_series=data_time_series,
+                static=data_static,
+                targets=None,
+            )
+
+
+# --- OneOffPredictionDataset. ---
+
+
+class MockedOneOffPredictionDataset(dataset.OneOffPredictionDataset):
+    mock_validate_call: Mock
+
+    def validate(self) -> None:
+        self._validate()
+
+    def _validate(self) -> None:
+        self.mock_validate_call()
+
+
+@pytest.fixture
+def mock_one_off_prediction_dataset_cls():
+    MockedOneOffPredictionDataset.mock_validate_call = Mock()
+    return MockedOneOffPredictionDataset
+
+
+class TestOneOffPredictionDataset:
+    def test_init(
+        self, mock_one_off_prediction_dataset_cls: Type[MockedOneOffPredictionDataset], mocked_depends: MockedDepends
+    ):
+        data_time_series = Mock()
+        data_static = Mock()
+        data_targets = Mock()
+
+        dummy_dataset = mock_one_off_prediction_dataset_cls(
+            time_series=data_time_series,
+            static=data_static,
+            targets=data_targets,
+            **{"dummy": "kwarg"},
+        )
+
+        mocked_depends.TimeSeriesSamples.assert_called_once_with(data_time_series)
+        mocked_depends.StaticSamples.assert_called_once_with(data_static)
+        mocked_depends.OneOffPredictionTaskData.assert_called_once_with(targets=data_targets, dummy="kwarg")
+        dummy_dataset.mock_validate_call.assert_called_once()
+
+    def test_validate_passes(
+        self, mock_one_off_prediction_dataset_cls: Type[MockedOneOffPredictionDataset], monkeypatch
+    ):
+        data_time_series = Mock()
+        data_static = None
+        data_targets = Mock()
+        mock_sample_index(monkeypatch, cls_name="TimeSeriesSamples", sample_index=["s1", "s2"])
+        mock_predictive_data_sample_index(
+            monkeypatch,
+            cls_name="OneOffPredictionTaskData",
+            targets_sample_index=["s1", "s2"],
+            treatments_sample_index=None,
+        )
+
+        mock_one_off_prediction_dataset_cls._validate = dataset.OneOffPredictionDataset._validate  # type: ignore
+        # ^ Enable validation code.
+
+        mock_one_off_prediction_dataset_cls(
+            time_series=data_time_series,
+            static=data_static,
+            targets=data_targets,
+        )
+
+    def test_validate_fails_sample_index_mismatch(
+        self, mock_one_off_prediction_dataset_cls: Type[MockedOneOffPredictionDataset], monkeypatch
+    ):
+        data_time_series = Mock()
+        data_static = None
+        data_targets = Mock()
+        mock_sample_index(monkeypatch, cls_name="TimeSeriesSamples", sample_index=["s1", "s2"])
+        mock_predictive_data_sample_index(
+            monkeypatch,
+            cls_name="OneOffPredictionTaskData",
+            targets_sample_index=["s1", "s2", "s3"],
+            treatments_sample_index=None,
+        )
+
+        mock_one_off_prediction_dataset_cls._validate = dataset.OneOffPredictionDataset._validate  # type: ignore
+        # ^ Enable validation code.
+
+        with pytest.raises(ValueError, match=".*sample_index.*targets.*time series.*"):
+            mock_one_off_prediction_dataset_cls(
+                time_series=data_time_series,
+                static=data_static,
+                targets=data_targets,
+            )
+
+
+# --- TemporalPredictionDataset. ---
+
+
+class MockedTemporalPredictionDataset(dataset.TemporalPredictionDataset):
+    mock_validate_call: Mock
+
+    def validate(self) -> None:
+        self._validate()
+
+    def _validate(self) -> None:
+        self.mock_validate_call()
+
+
+@pytest.fixture
+def mock_temporal_prediction_dataset_cls():
+    MockedTemporalPredictionDataset.mock_validate_call = Mock()
+    return MockedTemporalPredictionDataset
+
+
+class TestTemporalPredictionDataset:
+    def test_init(
+        self, mock_temporal_prediction_dataset_cls: Type[MockedTemporalPredictionDataset], mocked_depends: MockedDepends
+    ):
+        data_time_series = Mock()
+        data_static = Mock()
+        data_targets = Mock()
+
+        dummy_dataset = mock_temporal_prediction_dataset_cls(
+            time_series=data_time_series,
+            static=data_static,
+            targets=data_targets,
+            **{"dummy": "kwarg"},
+        )
+
+        mocked_depends.TimeSeriesSamples.assert_called_once_with(data_time_series)
+        mocked_depends.StaticSamples.assert_called_once_with(data_static)
+        mocked_depends.TemporalPredictionTaskData.assert_called_once_with(targets=data_targets, dummy="kwarg")
+        dummy_dataset.mock_validate_call.assert_called_once()
+
+    def test_validate_passes(
+        self, mock_temporal_prediction_dataset_cls: Type[MockedTemporalPredictionDataset], monkeypatch
+    ):
+        data_time_series = Mock()
+        data_static = None
+        data_targets = Mock()
+        mock_sample_index(monkeypatch, cls_name="TimeSeriesSamples", sample_index=["s1", "s2"])
+        mock_predictive_data_sample_index(
+            monkeypatch,
+            cls_name="TemporalPredictionTaskData",
+            targets_sample_index=["s1", "s2"],
+            treatments_sample_index=None,
+        )
+
+        mock_temporal_prediction_dataset_cls._validate = dataset.TemporalPredictionDataset._validate  # type: ignore
+        # ^ Enable validation code.
+
+        mock_temporal_prediction_dataset_cls(
+            time_series=data_time_series,
+            static=data_static,
+            targets=data_targets,
+        )
+
+    def test_validate_fails_sample_index_mismatch(
+        self, mock_temporal_prediction_dataset_cls: Type[MockedTemporalPredictionDataset], monkeypatch
+    ):
+        data_time_series = Mock()
+        data_static = None
+        data_targets = Mock()
+        mock_sample_index(monkeypatch, cls_name="TimeSeriesSamples", sample_index=["s1", "s2"])
+        mock_predictive_data_sample_index(
+            monkeypatch,
+            cls_name="TemporalPredictionTaskData",
+            targets_sample_index=["s1", "s2", "s3"],
+            treatments_sample_index=None,
+        )
+
+        mock_temporal_prediction_dataset_cls._validate = dataset.TemporalPredictionDataset._validate  # type: ignore
+        # ^ Enable validation code.
+
+        with pytest.raises(ValueError, match=".*sample_index.*targets.*time series.*"):
+            mock_temporal_prediction_dataset_cls(
+                time_series=data_time_series,
+                static=data_static,
+                targets=data_targets,
+            )
+
+
+# --- TimeToEventAnalysisDataset. ---
+
+
+class MockedTimeToEventAnalysisDataset(dataset.TimeToEventAnalysisDataset):
+    mock_validate_call: Mock
+
+    def validate(self) -> None:
+        self._validate()
+
+    def _validate(self) -> None:
+        self.mock_validate_call()
+
+
+@pytest.fixture
+def mock_time_to_event_analysis_dataset_cls():
+    MockedTimeToEventAnalysisDataset.mock_validate_call = Mock()
+    return MockedTimeToEventAnalysisDataset
+
+
+class TestTimeToEventAnalysisDataset:
+    def test_init(
+        self,
+        mock_time_to_event_analysis_dataset_cls: Type[MockedTimeToEventAnalysisDataset],
+        mocked_depends: MockedDepends,
+    ):
+        data_time_series = Mock()
+        data_static = Mock()
+        data_targets = Mock()
+
+        dummy_dataset = mock_time_to_event_analysis_dataset_cls(
+            time_series=data_time_series,
+            static=data_static,
+            targets=data_targets,
+            **{"dummy": "kwarg"},
+        )
+
+        mocked_depends.TimeSeriesSamples.assert_called_once_with(data_time_series)
+        mocked_depends.StaticSamples.assert_called_once_with(data_static)
+        mocked_depends.TimeToEventAnalysisTaskData.assert_called_once_with(targets=data_targets, dummy="kwarg")
+        dummy_dataset.mock_validate_call.assert_called_once()
+
+    def test_validate_passes(
+        self, mock_time_to_event_analysis_dataset_cls: Type[MockedTimeToEventAnalysisDataset], monkeypatch
+    ):
+        data_time_series = Mock()
+        data_static = None
+        data_targets = Mock()
+        mock_sample_index(monkeypatch, cls_name="TimeSeriesSamples", sample_index=["s1", "s2"])
+        mock_predictive_data_sample_index(
+            monkeypatch,
+            cls_name="TimeToEventAnalysisTaskData",
+            targets_sample_index=["s1", "s2"],
+            treatments_sample_index=None,
+        )
+
+        mock_time_to_event_analysis_dataset_cls._validate = dataset.TimeToEventAnalysisDataset._validate  # type: ignore
+        # ^ Enable validation code.
+
+        mock_time_to_event_analysis_dataset_cls(
+            time_series=data_time_series,
+            static=data_static,
+            targets=data_targets,
+        )
+
+    def test_validate_fails_sample_index_mismatch(
+        self, mock_time_to_event_analysis_dataset_cls: Type[MockedTimeToEventAnalysisDataset], monkeypatch
+    ):
+        data_time_series = Mock()
+        data_static = None
+        data_targets = Mock()
+        mock_sample_index(monkeypatch, cls_name="TimeSeriesSamples", sample_index=["s1", "s2"])
+        mock_predictive_data_sample_index(
+            monkeypatch,
+            cls_name="TimeToEventAnalysisTaskData",
+            targets_sample_index=["s1", "s2", "s3"],
+            treatments_sample_index=None,
+        )
+
+        mock_time_to_event_analysis_dataset_cls._validate = dataset.TimeToEventAnalysisDataset._validate  # type: ignore
+        # ^ Enable validation code.
+
+        with pytest.raises(ValueError, match=".*sample_index.*targets.*time series.*"):
+            mock_time_to_event_analysis_dataset_cls(
+                time_series=data_time_series,
+                static=data_static,
+                targets=data_targets,
+            )
+
+
+# --- OneOffTreatmentEffectsDataset. ---
+
+
+class MockedOneOffTreatmentEffectsDataset(dataset.OneOffTreatmentEffectsDataset):
+    mock_validate_call: Mock
+
+    def validate(self) -> None:
+        self._validate()
+
+    def _validate(self) -> None:
+        self.mock_validate_call()
+
+
+@pytest.fixture
+def mock_one_off_treatment_effects_dataset_cls():
+    MockedOneOffTreatmentEffectsDataset.mock_validate_call = Mock()
+    return MockedOneOffTreatmentEffectsDataset
+
+
+class TestOneOffTreatmentEffectsDataset:
+    def test_init(
+        self,
+        mock_one_off_treatment_effects_dataset_cls: Type[MockedOneOffTreatmentEffectsDataset],
+        mocked_depends: MockedDepends,
+    ):
+        data_time_series = Mock()
+        data_static = Mock()
+        data_targets = Mock()
+        data_treatments = Mock()
+
+        dummy_dataset = mock_one_off_treatment_effects_dataset_cls(
+            time_series=data_time_series,
+            static=data_static,
+            targets=data_targets,
+            treatments=data_treatments,
+            **{"dummy": "kwarg"},
+        )
+
+        mocked_depends.TimeSeriesSamples.assert_called_once_with(data_time_series)
+        mocked_depends.StaticSamples.assert_called_once_with(data_static)
+        mocked_depends.OneOffTreatmentEffectsTaskData.assert_called_once_with(
+            targets=data_targets, treatments=data_treatments, dummy="kwarg"
+        )
+        dummy_dataset.mock_validate_call.assert_called_once()
+
+    def test_init_fails_treatment_not_set(
+        self,
+        mock_one_off_treatment_effects_dataset_cls: Type[MockedOneOffTreatmentEffectsDataset],
+        mocked_depends: MockedDepends,
+    ):
+        data_time_series = Mock()
+        data_static = Mock()
+        data_targets = Mock()
+
+        with pytest.raises(ValueError, match=".*requires.*treatment.*"):
+            mock_one_off_treatment_effects_dataset_cls(
+                time_series=data_time_series,
+                static=data_static,
+                targets=data_targets,
+                treatments=None,
+            )
+
+    def test_validate_passes(
+        self, mock_one_off_treatment_effects_dataset_cls: Type[MockedOneOffTreatmentEffectsDataset], monkeypatch
+    ):
+        data_time_series = Mock()
+        data_static = None
+        data_targets = Mock()
+        data_treatments = Mock()
+        mock_sample_index(monkeypatch, cls_name="TimeSeriesSamples", sample_index=["s1", "s2"])
+        mock_predictive_data_sample_index(
+            monkeypatch,
+            cls_name="OneOffTreatmentEffectsTaskData",
+            targets_sample_index=["s1", "s2"],
+            treatments_sample_index=["s1", "s2"],
+        )
+
+        mock_one_off_treatment_effects_dataset_cls._validate = dataset.OneOffTreatmentEffectsDataset._validate  # type: ignore
+        # ^ Enable validation code.
+
+        mock_one_off_treatment_effects_dataset_cls(
+            time_series=data_time_series,
+            static=data_static,
+            targets=data_targets,
+            treatments=data_treatments,
+        )
+
+    @pytest.mark.parametrize(
+        "targets_sample_index, treatments_sample_index, match_exc",
+        [
+            (["s1", "s2", "s3"], ["s1", "s2"], ".*sample_index.*targets.*time series.*"),
+            (["s1", "s2"], ["s1", "s2", "s3"], ".*sample_index.*treatments.*time series.*"),
+        ],
+    )
+    def test_validate_fails_sample_index_mismatch(
+        self,
+        mock_one_off_treatment_effects_dataset_cls: Type[MockedOneOffTreatmentEffectsDataset],
+        monkeypatch,
+        targets_sample_index,
+        treatments_sample_index,
+        match_exc,
+    ):
+        data_time_series = Mock()
+        data_static = None
+        data_targets = Mock()
+        data_treatments = Mock()
+        mock_sample_index(monkeypatch, cls_name="TimeSeriesSamples", sample_index=["s1", "s2"])
+        mock_predictive_data_sample_index(
+            monkeypatch,
+            cls_name="OneOffTreatmentEffectsTaskData",
+            targets_sample_index=targets_sample_index,
+            treatments_sample_index=treatments_sample_index,
+        )
+
+        mock_one_off_treatment_effects_dataset_cls._validate = dataset.OneOffTreatmentEffectsDataset._validate  # type: ignore
+        # ^ Enable validation code.
+
+        with pytest.raises(ValueError, match=match_exc):
+            mock_one_off_treatment_effects_dataset_cls(
+                time_series=data_time_series,
+                static=data_static,
+                targets=data_targets,
+                treatments=data_treatments,
+            )
+
+
+# --- TemporalTreatmentEffectsDataset. ---
+
+
+class MockedTemporalTreatmentEffectsDataset(dataset.TemporalTreatmentEffectsDataset):
+    mock_validate_call: Mock
+
+    def validate(self) -> None:
+        self._validate()
+
+    def _validate(self) -> None:
+        self.mock_validate_call()
+
+
+@pytest.fixture
+def mock_temporal_treatment_effects_dataset_cls():
+    MockedTemporalTreatmentEffectsDataset.mock_validate_call = Mock()
+    return MockedTemporalTreatmentEffectsDataset
+
+
+class TestTemporalTreatmentEffectsDataset:
+    def test_init(
+        self,
+        mock_temporal_treatment_effects_dataset_cls: Type[MockedTemporalTreatmentEffectsDataset],
+        mocked_depends: MockedDepends,
+    ):
+        data_time_series = Mock()
+        data_static = Mock()
+        data_targets = Mock()
+        data_treatments = Mock()
+
+        dummy_dataset = mock_temporal_treatment_effects_dataset_cls(
+            time_series=data_time_series,
+            static=data_static,
+            targets=data_targets,
+            treatments=data_treatments,
+            **{"dummy": "kwarg"},
+        )
+
+        mocked_depends.TimeSeriesSamples.assert_called_once_with(data_time_series)
+        mocked_depends.StaticSamples.assert_called_once_with(data_static)
+        mocked_depends.TemporalTreatmentEffectsTaskData.assert_called_once_with(
+            targets=data_targets, treatments=data_treatments, dummy="kwarg"
+        )
+        dummy_dataset.mock_validate_call.assert_called_once()
+
+    def test_init_fails_treatment_not_set(
+        self,
+        mock_temporal_treatment_effects_dataset_cls: Type[MockedTemporalTreatmentEffectsDataset],
+        mocked_depends: MockedDepends,
+    ):
+        data_time_series = Mock()
+        data_static = Mock()
+        data_targets = Mock()
+
+        with pytest.raises(ValueError, match=".*requires.*treatment.*"):
+            mock_temporal_treatment_effects_dataset_cls(
+                time_series=data_time_series,
+                static=data_static,
+                targets=data_targets,
+                treatments=None,
+            )
+
+    def test_validate_passes(
+        self, mock_temporal_treatment_effects_dataset_cls: Type[MockedTemporalTreatmentEffectsDataset], monkeypatch
+    ):
+        data_time_series = Mock()
+        data_static = None
+        data_targets = Mock()
+        data_treatments = Mock()
+        mock_sample_index(monkeypatch, cls_name="TimeSeriesSamples", sample_index=["s1", "s2"])
+        mock_predictive_data_sample_index(
+            monkeypatch,
+            cls_name="TemporalTreatmentEffectsTaskData",
+            targets_sample_index=["s1", "s2"],
+            treatments_sample_index=["s1", "s2"],
+        )
+
+        mock_temporal_treatment_effects_dataset_cls._validate = dataset.TemporalTreatmentEffectsDataset._validate  # type: ignore
+        # ^ Enable validation code.
+
+        mock_temporal_treatment_effects_dataset_cls(
+            time_series=data_time_series,
+            static=data_static,
+            targets=data_targets,
+            treatments=data_treatments,
+        )
+
+    @pytest.mark.parametrize(
+        "targets_sample_index, treatments_sample_index, match_exc",
+        [
+            (["s1", "s2", "s3"], ["s1", "s2"], ".*sample_index.*targets.*time series.*"),
+            (["s1", "s2"], ["s1", "s2", "s3"], ".*sample_index.*treatments.*time series.*"),
+        ],
+    )
+    def test_validate_fails_sample_index_mismatch(
+        self,
+        mock_temporal_treatment_effects_dataset_cls: Type[MockedTemporalTreatmentEffectsDataset],
+        monkeypatch,
+        targets_sample_index,
+        treatments_sample_index,
+        match_exc,
+    ):
+        data_time_series = Mock()
+        data_static = None
+        data_targets = Mock()
+        data_treatments = Mock()
+        mock_sample_index(monkeypatch, cls_name="TimeSeriesSamples", sample_index=["s1", "s2"])
+        mock_predictive_data_sample_index(
+            monkeypatch,
+            cls_name="TemporalTreatmentEffectsTaskData",
+            targets_sample_index=targets_sample_index,
+            treatments_sample_index=treatments_sample_index,
+        )
+
+        mock_temporal_treatment_effects_dataset_cls._validate = dataset.TemporalTreatmentEffectsDataset._validate  # type: ignore
+        # ^ Enable validation code.
+
+        with pytest.raises(ValueError, match=match_exc):
+            mock_temporal_treatment_effects_dataset_cls(
+                time_series=data_time_series,
+                static=data_static,
+                targets=data_targets,
+                treatments=data_treatments,
+            )
+
+
+# TODO: May want to add some tests with actual data frame / numpy array inputs.
diff --git a/tests/data/test_requirements.py b/tests/data/test_requirements.py
deleted file mode 100644
index 711f8554..00000000
--- a/tests/data/test_requirements.py
+++ /dev/null
@@ -1,64 +0,0 @@
-# pylint: disable=redefined-outer-name, unused-argument
-
-from unittest.mock import Mock
-
-import pytest
-
-import tempor.data.requirements as r
-
-MockCategoryA = Mock()
-MockCategoryB = Mock()
-MockCategoryC = Mock()
-
-
-@pytest.fixture
-def empty_out_data_requirements(monkeypatch):
-    empty = {
-        MockCategoryA: set(),
-        MockCategoryB: set(),
-        MockCategoryC: set(),
-    }
-    monkeypatch.setattr(r, "DATA_REQUIREMENTS", empty)
-
-
-def test_create_data_requirements_success(empty_out_data_requirements):
-    class SomeRequirement(r.DataRequirement):
-        categories = {MockCategoryA}
-
-    class AnotherRequirement(r.DataRequirement):
-        categories = {MockCategoryA, MockCategoryB}
-
-    class YetAnotherRequirement(r.DataRequirement):
-        categories = {MockCategoryA, MockCategoryB, MockCategoryC}
-
-    reqs_registry = r.DATA_REQUIREMENTS
-
-    assert type(SomeRequirement) == r.DataRequirementMeta
-    assert reqs_registry[MockCategoryA] == {SomeRequirement, AnotherRequirement, YetAnotherRequirement}
-    assert reqs_registry[MockCategoryB] == {AnotherRequirement, YetAnotherRequirement}
-    assert reqs_registry[MockCategoryC] == {YetAnotherRequirement}
-
-
-def test_disallow_empty_categories(empty_out_data_requirements):
-    with pytest.raises(TypeError, match=".*at least one.*"):
-
-        class EmptyCategories(r.DataRequirement):  # pylint: disable=unused-variable
-            categories = {}  # pyright: ignore
-
-
-def test_wrong_names(monkeypatch):
-    with monkeypatch.context() as m:
-        m.setattr(r.DataRequirementMeta, "_base_class_name", "WrongNameBaseClass")
-
-        with pytest.raises(TypeError, match=".*WrongNameBaseClass.*"):
-
-            class BaseClassWasWrongNameFailure(r.DataRequirement):  # pylint: disable=unused-variable
-                pass
-
-    with monkeypatch.context() as m:
-        m.setattr(r.DataRequirementMeta, "_categories_attr_name", "wrong_attr")
-
-        with pytest.raises(TypeError, match=".*wrong_att*"):
-
-            class CategoriesAttrWrongNameFailure(r.DataRequirement):  # pylint: disable=unused-variable
-                pass
diff --git a/tests/data/test_samples.py b/tests/data/test_samples.py
index 317ec7bd..4b165675 100644
--- a/tests/data/test_samples.py
+++ b/tests/data/test_samples.py
@@ -1,26 +1,682 @@
-# pylint: disable=redefined-outer-name, unused-argument
+# pylint: disable=redefined-outer-name
 
+import dataclasses
+import re
+from typing import List, Tuple
+from unittest.mock import Mock
+
+import numpy as np
+import pandas as pd
 import pytest
 
-import tempor.data.samples as s
+import tempor.exc
+from tempor.data import data_typing, samples
+
+PAD = 999.0
+
+
+@dataclasses.dataclass
+class DFsTest:
+    # Static:
+    df_static_success: List[pd.DataFrame]
+    df_static_fail: List[Tuple[pd.DataFrame, str]]  # (Failing DataFrame, str to match in error msg)
+    # TimeSeries:
+    df_time_series_success: List[pd.DataFrame]
+    df_time_series_fail: List[Tuple[pd.DataFrame, str]]
+    # Event:
+    df_event_success: List[pd.DataFrame]
+    df_event_fail: List[Tuple[pd.DataFrame, str]]
+
+
+def set_up_dfs_test():
+
+    # --- Static. ---
+
+    categories = ["A", "B", "C"]
+    np.random.seed(12345)
+    size = 10
+    df_s_success_nonan = pd.DataFrame(
+        {
+            "sample_idx": [f"sample_{x}" for x in range(1, size + 1)],
+            "cat_feat_1": pd.Categorical(np.random.choice(categories, size=size)),
+            "cat_feat_2": pd.Categorical(np.random.choice(categories, size=size)),
+            "num_feat_1": np.random.uniform(0, 10, size=size),
+            "num_feat_2": np.random.uniform(20, 30, size=size),
+        }
+    )
+    df_s_success_nonan.set_index("sample_idx", drop=True, inplace=True)
+
+    df_s_success_nan = df_s_success_nonan.copy()
+    df_s_success_nan.loc[0, "num_feat_1"] = np.nan
+
+    df_s_fail_index_multiindex = df_s_success_nonan.copy()
+    df_s_fail_index_multiindex["sample_index"] = list(df_s_fail_index_multiindex.index)
+    df_s_fail_index_multiindex["some_other_index"] = [f"something_{x}" for x in range(100, size + 100)]
+    df_s_fail_index_multiindex.set_index(keys=["sample_index", "some_other_index"], drop=True, inplace=True)
+
+    df_s_fail_index_nonunique = df_s_success_nonan.copy()
+    df_s_fail_index_nonunique = pd.concat([df_s_fail_index_nonunique] * 2)
+
+    df_s_fail_index_dtype_float = df_s_success_nonan.copy()
+    df_s_fail_index_dtype_float["sample_idx"] = list([x + 0.5 for x in range(len(df_s_success_nonan))])
+    df_s_fail_index_dtype_float.set_index(keys=["sample_idx"], drop=True, inplace=True)
+
+    df_s_fail_columns_multiindex = pd.DataFrame(
+        data=df_s_success_nonan.to_numpy(),
+        columns=pd.MultiIndex.from_tuples([("something", x) for x in df_s_success_nonan.columns]),
+    )
+
+    df_s_fail_values_dtype_string = df_s_success_nonan.copy()
+    df_s_fail_values_dtype_string["cat_feat_1"] = [f"{x}" for x in df_s_success_nonan["cat_feat_1"].to_list()]
+
+    # --- TimeSeries. ---
+
+    df_t_success_nonan = pd.DataFrame(
+        {
+            "sample_idx": ["a", "a", "a", "a", "b", "b", "c"],
+            "time_idx": [1, 2, 3, 4, 2, 4, 9],
+            "feat_1": [11, 12, 13, 14, 21, 22, 31],
+            "feat_2": [1.1, 1.2, 1.3, 1.4, 2.1, 2.2, 3.1],
+        }
+    )
+    df_t_success_nonan.set_index(keys=["sample_idx", "time_idx"], drop=True, inplace=True)
+
+    df_t_success_nan = df_t_success_nonan.copy()
+    df_t_success_nan.loc[("a", 3), "feat_2"] = np.nan
+
+    df_t_fail_index_singleindex = df_t_success_nonan.copy()
+    df_t_fail_index_singleindex["sample_idx"] = list(df_t_fail_index_singleindex.index)
+    df_t_fail_index_singleindex.set_index(keys=["sample_idx"], drop=True, inplace=True)
+
+    df_t_fail_index_nonunique = df_t_success_nonan.copy()
+    df_t_fail_index_nonunique = pd.concat([df_t_fail_index_nonunique] * 2)
+
+    df_t_fail_index_sample_idx_dtype_float = df_t_success_nonan.copy()
+    df_t_fail_index_sample_idx_dtype_float["sample_idx"] = list([x + 0.5 for x in range(len(df_t_success_nonan))])
+    df_t_fail_index_sample_idx_dtype_float["time_idx"] = list(df_t_success_nonan.index.get_level_values(1))
+    df_t_fail_index_sample_idx_dtype_float.set_index(keys=["sample_idx", "time_idx"], drop=True, inplace=True)
+
+    df_s_fail_index_time_idx_dtype_str = df_t_success_nonan.copy()
+    df_s_fail_index_time_idx_dtype_str["sample_idx"] = list(
+        df_s_fail_index_time_idx_dtype_str.index.get_level_values(0)
+    )
+    df_s_fail_index_time_idx_dtype_str["time_idx"] = list([f"t_{x}" for x in range(len(df_t_success_nonan))])
+    df_s_fail_index_time_idx_dtype_str.set_index(keys=["sample_idx", "time_idx"], drop=True, inplace=True)
+
+    df_t_fail_columns_multiindex = pd.DataFrame(
+        data=df_t_success_nonan.to_numpy(),
+        columns=pd.MultiIndex.from_tuples([("something", x) for x in df_t_success_nonan.columns]),
+    )
+
+    df_t_fail_values_dtype_string = df_t_success_nonan.copy()
+    df_t_fail_values_dtype_string["feat_2"] = [f"{x}" for x in df_t_success_nonan["feat_2"].to_list()]
+
+    # --- Event. ---
+
+    df_e_success = pd.DataFrame(
+        {
+            "sample_idx": [f"sample_{x}" for x in range(1, 3 + 1)],
+            "feat_1": [(5, True), (6, False), (3, True)],
+            "feat_2": [(1, False), (8, False), (8, True)],
+            "feat_3": [
+                (pd.to_datetime("2000-01-02"), False),
+                (pd.to_datetime("2000-01-03"), True),
+                (pd.to_datetime("2000-01-01"), True),
+            ],
+        },
+    )
+    df_e_success.set_index("sample_idx", drop=True, inplace=True)
+
+    df_e_fail_index_multiindex = df_e_success.copy()
+    df_e_fail_index_multiindex["sample_index"] = list(df_e_success.index)
+    df_e_fail_index_multiindex["some_other_index"] = [
+        f"something_{x}" for x in range(100, len(df_e_fail_index_multiindex) + 100)
+    ]
+    df_e_fail_index_multiindex.set_index(keys=["sample_index", "some_other_index"], drop=True, inplace=True)
+
+    df_e_fail_index_nonunique = df_e_success.copy()
+    df_e_fail_index_nonunique = pd.concat([df_e_fail_index_nonunique] * 2)
+
+    df_e_fail_index_dtype_float = df_e_success.copy()
+    df_e_fail_index_dtype_float["sample_idx"] = list([x + 0.5 for x in range(len(df_e_success))])
+    df_e_fail_index_dtype_float.set_index(keys=["sample_idx"], drop=True, inplace=True)
+
+    df_e_fail_columns_multiindex = pd.DataFrame(
+        data=df_e_success.to_numpy(),
+        columns=pd.MultiIndex.from_tuples([("something", x) for x in df_e_success.columns]),
+    )
+
+    df_e_fail_values_non_len2_seq = df_e_success.copy()
+    df_e_fail_values_non_len2_seq.loc[:, "feat_4"] = "abc"
+
+    df_e_fail_values_dtype_unexp_event_value = df_e_success.copy()
+    df_e_fail_values_dtype_unexp_event_value.loc["sample_2", "feat_2"] = (8, "abc")  # pyright: ignore
+
+    df_e_fail_values_dtype_unexp_event_time = df_e_success.copy()
+    df_e_fail_values_dtype_unexp_event_time.loc["sample_2", "feat_2"] = ("abc", False)  # pyright: ignore
+
+    # --- Record all dataframes for testing. ---
+
+    dfs_test = DFsTest(
+        df_static_success=[
+            df_s_success_nonan,
+            df_s_success_nan,
+        ],
+        df_static_fail=[
+            (df_s_fail_index_multiindex, r".*MultiIndex.*index.*not allowed.*"),
+            (df_s_fail_index_nonunique, r".*sample_idx.*duplicate.*"),
+            (df_s_fail_index_dtype_float, r".*DataFrame.*index.*dtype.*"),
+            (df_s_fail_columns_multiindex, r".*MultiIndex.*columns.*not allowed.*"),
+            (df_s_fail_values_dtype_string, r".*DataFrame.*dtype.*"),
+        ],
+        df_time_series_success=[
+            df_t_success_nonan,
+            df_t_success_nan,
+        ],
+        df_time_series_fail=[
+            (df_t_fail_index_singleindex, r".*must.*MultiIndex.*2 levels.*"),
+            (df_t_fail_index_nonunique, r".*sample_idx.*time_idx.*not unique.*"),
+            (df_s_fail_index_time_idx_dtype_str, r".*DataFrame.*index.*dtype"),
+            (df_t_fail_index_sample_idx_dtype_float, r".*DataFrame.*index.*dtype.*"),
+            (df_t_fail_columns_multiindex, r".*MultiIndex.*columns.*not allowed.*"),
+            (df_t_fail_values_dtype_string, r".*DataFrame.*dtype.*"),
+        ],
+        df_event_success=[
+            df_e_success,
+        ],
+        df_event_fail=[
+            (df_e_fail_index_multiindex, r".*MultiIndex.*index.*not allowed.*"),
+            (df_e_fail_index_nonunique, r".*sample_idx.*duplicate.*"),
+            (df_e_fail_index_dtype_float, r".*DataFrame.*index.*dtype.*"),
+            (df_e_fail_columns_multiindex, r".*MultiIndex.*columns.*not allowed.*"),
+            (df_e_fail_values_non_len2_seq, r".*sequence.*length 2.*"),
+            (df_e_fail_values_dtype_unexp_event_value, r".*feat_2.*DataFrame.*dtype.*"),
+            (df_e_fail_values_dtype_unexp_event_time, r".*feat_2_time.*DataFrame.*dtype.*"),
+        ],
+    )
+
+    return dfs_test
+
+
+dfs_test: DFsTest = set_up_dfs_test()
+
+
+@pytest.fixture
+def df_static() -> pd.DataFrame:
+    return dfs_test.df_static_success[0]
 
 
 @pytest.fixture
-def df_static(df_static_cat_num_hasnan):
-    return df_static_cat_num_hasnan
+def df_time_series() -> pd.DataFrame:
+    return dfs_test.df_time_series_success[0]
 
 
 @pytest.fixture
-def df_time_series(df_time_series_num_nonan):
-    return df_time_series_num_nonan
+def df_event() -> pd.DataFrame:
+    return dfs_test.df_event_success[0]
 
 
 @pytest.fixture
-def df_event(df_event_num_nonan):
-    return df_event_num_nonan
+def array_static() -> np.ndarray:
+    return np.random.uniform(0, 10, size=(4, 3))
+
+
+@pytest.fixture
+def array_event() -> np.ndarray:
+    return dfs_test.df_event_success[0].to_numpy()
+
+
+class TestStaticSamples:
+    def test_modality(self, df_static: pd.DataFrame):
+        s = samples.StaticSamples(data=df_static)
+        assert s.modality == data_typing.DataModality.STATIC
+
+    def test_num_samples(self, df_static: pd.DataFrame):
+        s = samples.StaticSamples(data=df_static)
+        assert s.num_samples == 10
+        assert s.num_samples == len(s)
+
+    def test_num_features(self, df_static: pd.DataFrame):
+        s = samples.StaticSamples(data=df_static)
+        assert s.num_features == 4
+
+    def test_dataframe(self, df_static: pd.DataFrame):
+        s = samples.StaticSamples(data=df_static)
+        assert s.dataframe().equals(df_static)
+
+    def test_numpy(self, df_static: pd.DataFrame):
+        s = samples.StaticSamples(data=df_static)
+        assert (s.numpy() == s._data.to_numpy()).all()  # pylint: disable=protected-access
+
+    def test_from_dataframe(self, df_static: pd.DataFrame):
+        s = samples.StaticSamples.from_dataframe(df_static)
+        assert s.dataframe().equals(df_static)
+
+    @pytest.mark.parametrize(
+        "sample_index, feature_index, expected_sample_index, expected_feature_index",
+        [
+            (None, None, [0, 1, 2, 3], ["feat_0", "feat_1", "feat_2"]),
+            (["s1", "s2", "s3", "s4"], ["f1", "f2", "f3"], ["s1", "s2", "s3", "s4"], ["f1", "f2", "f3"]),
+        ],
+    )
+    def test_from_numpy(
+        self, array_static: np.ndarray, sample_index, feature_index, expected_sample_index, expected_feature_index
+    ):
+        s = samples.StaticSamples.from_numpy(array_static, sample_index=sample_index, feature_index=feature_index)
+        df = s._data  # pylint: disable=protected-access
+        assert (df.to_numpy() == array_static).all()
+        assert list(df.index) == expected_sample_index
+        assert list(df.columns) == expected_feature_index
+
+    def test_sample_index(self, df_static: pd.DataFrame):
+        s = samples.StaticSamples.from_dataframe(df_static)
+        assert s.sample_index() == [f"sample_{x}" for x in range(1, 10 + 1)]
+
+    def test_repr(self, df_static: pd.DataFrame):
+        s = samples.StaticSamples(data=df_static)
+        assert "StaticSamples with data:" in str(s)
+        assert str(s.dataframe()) in str(s)
+
+    @pytest.mark.parametrize("df", dfs_test.df_static_success)
+    def test_init_success(self, df: pd.DataFrame):
+        samples.StaticSamples(data=df)
+        assert df.index.name == "sample_idx"
+
+    @pytest.mark.parametrize("df, match_exc", dfs_test.df_static_fail)
+    def test_init_fail(self, df: pd.DataFrame, match_exc: str):
+        with pytest.raises(tempor.exc.DataValidationException) as excinfo:
+            samples.StaticSamples(data=df)
+        assert re.search(match_exc, str(excinfo.getrepr()), re.S | re.IGNORECASE)
+
+    def test_init_array_data_success(self, array_static: np.ndarray):
+        samples.StaticSamples(data=array_static)
+
+    def test_short_repr(self, df_static: pd.DataFrame):
+        s = samples.StaticSamples.from_dataframe(df_static)
+        assert s.short_repr() == "StaticSamples([10, 4])"
+
+
+class TestTimeSeriesSamples:
+    def test_modality(self, df_time_series: pd.DataFrame):
+        ss = samples.TimeSeriesSamples(data=df_time_series)
+        assert ss.modality == data_typing.DataModality.TIME_SERIES
+
+    def test_num_samples(self, df_time_series: pd.DataFrame):
+        s = samples.TimeSeriesSamples(data=df_time_series)
+        assert s.num_samples == 3
+        assert s.num_samples == len(s)
+
+    def test_num_features(self, df_time_series: pd.DataFrame):
+        s = samples.TimeSeriesSamples(data=df_time_series)
+        assert s.num_features == 2
+
+    def test_dataframe(self, df_time_series: pd.DataFrame):
+        s = samples.TimeSeriesSamples(data=df_time_series)
+        assert s.dataframe().equals(df_time_series)
+
+    def test_numpy(self, df_time_series: pd.DataFrame):
+        s = samples.TimeSeriesSamples(data=df_time_series)
+
+        pad = 999.0
+        array = s.numpy(padding_indicator=pad)
+        expected_array = np.transpose(
+            np.asarray(
+                [  # pyright: ignore
+                    [
+                        [11, 12, 13, 14],
+                        [1.1, 1.2, 1.3, 1.4],
+                    ],
+                    [
+                        [21, 22, pad, pad],
+                        [2.1, 2.2, pad, pad],
+                    ],
+                    [
+                        [31, pad, pad, pad],
+                        [3.1, pad, pad, pad],
+                    ],
+                ]
+            ),
+            (0, 2, 1),
+        )
+
+        assert (array == expected_array).all()
+
+    def test_from_dataframe(self, df_time_series: pd.DataFrame):
+        s = samples.TimeSeriesSamples.from_dataframe(df_time_series)
+        assert s.dataframe().equals(df_time_series)
+
+    @pytest.mark.parametrize(
+        "array_timeseries, padding_indicator, sample_index, time_indexes, feature_index, expected_df",
+        [
+            # Case: padded, indexes not provided.
+            (
+                # array_timeseries:
+                np.transpose(
+                    np.asarray(
+                        [  # pyright: ignore
+                            [
+                                [11, 12, 13, 14, PAD],
+                                [1.1, 1.2, 1.3, 1.4, PAD],
+                            ],
+                            [
+                                [21, 22, PAD, PAD, PAD],
+                                [2.1, 2.2, PAD, PAD, PAD],
+                            ],
+                            [
+                                [31, PAD, PAD, PAD, PAD],
+                                [3.1, PAD, PAD, PAD, PAD],
+                            ],
+                        ]
+                    ),
+                    (0, 2, 1),
+                ),
+                # padding_indicator:
+                PAD,
+                # sample_index:
+                None,
+                # time_indexes:
+                None,
+                # feature_index:
+                None,
+                # expected_df:
+                pd.DataFrame(
+                    data={
+                        "feat_0": [11.0, 12.0, 13.0, 14.0, 21.0, 22.0, 31.0],
+                        "feat_1": [1.1, 1.2, 1.3, 1.4, 2.1, 2.2, 3.1],
+                    },
+                    index=pd.MultiIndex.from_tuples(
+                        [
+                            (0, 0),
+                            (0, 1),
+                            (0, 2),
+                            (0, 3),
+                            (1, 0),
+                            (1, 1),
+                            (2, 0),
+                        ]
+                    ),
+                ),
+            ),
+            # Case: padded, indexes provided.
+            (
+                # array_timeseries:
+                np.transpose(
+                    np.asarray(
+                        [  # pyright: ignore
+                            [
+                                [11, 12, PAD],
+                                [1.1, 1.2, PAD],
+                            ],
+                            [
+                                [21, PAD, PAD],
+                                [2.1, PAD, PAD],
+                            ],
+                        ]
+                    ),
+                    (0, 2, 1),
+                ),
+                # padding_indicator:
+                PAD,
+                # sample_index:
+                ["sample_0", "sample_1"],
+                # time_indexes:
+                [[1.1, 1.2], [2.1]],
+                # feature_index:
+                ["f1", "f2"],
+                # expected_df:
+                pd.DataFrame(
+                    data={
+                        "f1": [11.0, 12.0, 21.0],
+                        "f2": [1.1, 1.2, 2.1],
+                    },
+                    index=pd.MultiIndex.from_tuples(
+                        [
+                            ("sample_0", 1.1),
+                            ("sample_0", 1.2),
+                            ("sample_1", 2.1),
+                        ]
+                    ),
+                ),
+            ),
+            # Case: no padding:
+            (
+                # array_timeseries:
+                np.transpose(
+                    np.asarray(
+                        [  # pyright: ignore
+                            [
+                                [11, 12, 13],
+                                [1.1, 1.2, 1.3],
+                            ],
+                            [
+                                [21, 22, 23],
+                                [2.1, 2.2, 2.3],
+                            ],
+                        ]
+                    ),
+                    (0, 2, 1),
+                ),
+                # padding_indicator:
+                None,
+                # sample_index:
+                None,
+                # time_indexes:
+                None,
+                # feature_index:
+                None,
+                # expected_df:
+                pd.DataFrame(
+                    data={
+                        "feat_0": [11.0, 12.0, 13.0, 21.0, 22.0, 23.0],
+                        "feat_1": [1.1, 1.2, 1.3, 2.1, 2.2, 2.3],
+                    },
+                    index=pd.MultiIndex.from_tuples(
+                        [
+                            (0, 0),
+                            (0, 1),
+                            (0, 2),
+                            (1, 0),
+                            (1, 1),
+                            (1, 2),
+                        ]
+                    ),
+                ),
+            ),
+            # Case: datetime time index:
+            (
+                # array_timeseries:
+                np.transpose(
+                    np.asarray(
+                        [  # pyright: ignore
+                            [
+                                [11, 12, 13],
+                                [1.1, 1.2, 1.3],
+                            ],
+                            [
+                                [21, PAD, PAD],
+                                [2.1, PAD, PAD],
+                            ],
+                        ]
+                    ),
+                    (0, 2, 1),
+                ),
+                # padding_indicator:
+                PAD,
+                # sample_index:
+                None,
+                # time_indexes:
+                [
+                    list(pd.to_datetime(["2000-01-01", "2000-01-02", "2000-01-03"])),
+                    list(pd.to_datetime(["2002-02-02"])),
+                ],
+                # feature_index:
+                None,
+                # expected_df:
+                pd.DataFrame(
+                    data={
+                        "feat_0": [11.0, 12.0, 13.0, 21.0],
+                        "feat_1": [1.1, 1.2, 1.3, 2.1],
+                    },
+                    index=pd.MultiIndex.from_tuples(
+                        [
+                            (0, pd.Timestamp("2000-01-01")),
+                            (0, pd.Timestamp("2000-01-02")),
+                            (0, pd.Timestamp("2000-01-03")),
+                            (1, pd.Timestamp("2002-02-02")),
+                        ]
+                    ),
+                ),
+            ),
+        ],
+    )
+    def test_from_numpy(
+        self,
+        array_timeseries: np.ndarray,
+        padding_indicator,
+        sample_index,
+        time_indexes,
+        feature_index,
+        expected_df: pd.DataFrame,
+    ):
+        s = samples.TimeSeriesSamples.from_numpy(
+            array_timeseries,
+            padding_indicator=padding_indicator,
+            sample_index=sample_index,
+            time_indexes=time_indexes,
+            feature_index=feature_index,
+        )
+        df = s._data  # pylint: disable=protected-access
+        assert expected_df.equals(df)
+
+    def test_sample_index(self, df_time_series: pd.DataFrame):
+        s = samples.TimeSeriesSamples.from_dataframe(df_time_series)
+        assert s.sample_index() == ["a", "b", "c"]
+
+    def test_time_indexes(self, df_time_series: pd.DataFrame):
+        s = samples.TimeSeriesSamples.from_dataframe(df_time_series)
+        assert s.time_indexes() == [[1, 2, 3, 4], [2, 4], [9]]
+
+    def test_time_indexes_as_dict(self, df_time_series: pd.DataFrame):
+        s = samples.TimeSeriesSamples.from_dataframe(df_time_series)
+        assert s.time_indexes_as_dict() == {"a": [1, 2, 3, 4], "b": [2, 4], "c": [9]}
+
+    def test_repr(self, df_time_series: pd.DataFrame):
+        s = samples.TimeSeriesSamples.from_dataframe(df_time_series)
+        assert "TimeSeriesSamples with data:" in str(s)
+        assert str(s.dataframe()) in str(s)
+
+    @pytest.mark.parametrize("df", dfs_test.df_time_series_success)
+    def test_init_success(self, df: pd.DataFrame):
+        samples.TimeSeriesSamples(data=df)
+
+    @pytest.mark.parametrize("df, match_exc", dfs_test.df_time_series_fail)
+    def test_init_fail(self, df: pd.DataFrame, match_exc: str):
+        with pytest.raises(tempor.exc.DataValidationException) as excinfo:
+            samples.TimeSeriesSamples(data=df)
+        assert re.search(match_exc, str(excinfo.getrepr()), re.S | re.IGNORECASE)
+
+    def test_short_repr(self, df_time_series: pd.DataFrame):
+        s = samples.TimeSeriesSamples.from_dataframe(df_time_series)
+        assert s.short_repr() == "TimeSeriesSamples([3, *, 2])"
+
+
+class TestEventSamples:
+    def test_modality(self, df_event: pd.DataFrame):
+        s = samples.EventSamples(data=df_event)
+        assert s.modality == data_typing.DataModality.EVENT
+
+    def test_num_samples(self, df_event: pd.DataFrame):
+        s = samples.EventSamples(data=df_event)
+        assert s.num_samples == 3
+        assert s.num_samples == len(s)
+
+    def test_num_features(self, df_event: pd.DataFrame):
+        s = samples.EventSamples(data=df_event)
+        assert s.num_features == 3
+
+    def test_dataframe(self, df_event: pd.DataFrame):
+        s = samples.EventSamples(data=df_event)
+        assert s.dataframe().equals(df_event)
+
+    def test_numpy(self, df_event: pd.DataFrame):
+        s = samples.EventSamples(data=df_event)
+        assert (s.numpy() == s._data.to_numpy()).all()  # pylint: disable=protected-access
+
+    def test_from_dataframe(self, df_event: pd.DataFrame):
+        s = samples.EventSamples.from_dataframe(df_event)
+        assert s.dataframe().equals(df_event)
+
+    @pytest.mark.parametrize(
+        "sample_index, feature_index, expected_sample_index, expected_feature_index",
+        [
+            (None, None, [0, 1, 2], ["feat_0", "feat_1", "feat_2"]),
+            (["s1", "s2", "s3"], ["f1", "f2", "f3"], ["s1", "s2", "s3"], ["f1", "f2", "f3"]),
+        ],
+    )
+    def test_from_numpy(
+        self, array_event: np.ndarray, sample_index, feature_index, expected_sample_index, expected_feature_index
+    ):
+        s = samples.EventSamples.from_numpy(array_event, sample_index=sample_index, feature_index=feature_index)
+        df = s._data  # pylint: disable=protected-access
+        assert (df.to_numpy() == array_event).all()
+        assert list(df.index) == expected_sample_index
+        assert list(df.columns) == expected_feature_index
+
+    def test_sample_index(self, df_event: pd.DataFrame):
+        s = samples.EventSamples.from_dataframe(df_event)
+        assert s.sample_index() == [f"sample_{x}" for x in range(1, 3 + 1)]
+
+    def test_repr(self, df_event: pd.DataFrame):
+        s = samples.EventSamples.from_dataframe(df_event)
+        assert "EventSamples with data:" in str(s)
+        assert str(s.dataframe()) in str(s)
+
+    @pytest.mark.parametrize("df", dfs_test.df_event_success)
+    def test_init_success(self, df: pd.DataFrame):
+        samples.EventSamples(data=df)
+
+    @pytest.mark.parametrize("df, match_exc", dfs_test.df_event_fail)
+    def test_init_fail(self, df: pd.DataFrame, match_exc: str):
+        with pytest.raises(tempor.exc.DataValidationException) as excinfo:
+            samples.EventSamples(data=df)
+        assert re.search(match_exc, str(excinfo.getrepr()), re.S | re.IGNORECASE)
+
+    def test_init_array_data_success(self, array_event: np.ndarray):
+        samples.EventSamples(data=array_event)
+
+    def test_split(self, df_event: pd.DataFrame):
+        s = samples.EventSamples(data=df_event)
+        s_split = s.split(time_feature_suffix="_time")
+
+        expected_df = pd.DataFrame(
+            {
+                "sample_idx": [f"sample_{x}" for x in range(1, 3 + 1)],
+                "feat_1_time": [5, 6, 3],
+                "feat_1": [True, False, True],
+                "feat_2_time": [1, 8, 8],
+                "feat_2": [False, False, True],
+                "feat_3_time": [
+                    pd.to_datetime("2000-01-02"),
+                    pd.to_datetime("2000-01-03"),
+                    pd.to_datetime("2000-01-01"),
+                ],
+                "feat_3": [False, True, True],
+            },
+        )
+        expected_df.set_index("sample_idx", drop=True, inplace=True)
+
+        assert s_split.equals(expected_df)
+
+    def test_split_fails_column_naming_conflict(self):
+        df = pd.DataFrame({"feat_1_time": [(5, True), (6, False), (3, True)]})
+
+        samples.EventSamples.validate = Mock()  # Skip validation.
+
+        s = samples.EventSamples(data=df)
 
+        with pytest.raises(ValueError, match=".*[Cc]olumn.*not contain.*_time.*"):
+            s.split(time_feature_suffix="_time")
 
-def test_instantiation_success(df_static, df_time_series, df_event):
-    _ = s.StaticSamples(data=df_static)
-    _ = s.TimeSeriesSamples(data=df_time_series)
-    _ = s.EventSamples(data=df_event)
+    def test_short_repr(self, df_event: pd.DataFrame):
+        s = samples.EventSamples.from_dataframe(df_event)
+        assert s.short_repr() == "EventSamples([3, 3])"
diff --git a/tests/data/test_utils.py b/tests/data/test_utils.py
index ee20c5c0..0624d25b 100644
--- a/tests/data/test_utils.py
+++ b/tests/data/test_utils.py
@@ -1,21 +1,37 @@
 # pylint: disable=redefined-outer-name, unused-argument
 
+from typing import Any, List, Tuple
+from unittest.mock import Mock
+
 import numpy as np
+import pandas as pd
 import pytest
 
 import tempor.data.utils as utils
 
 
 @pytest.fixture
-def multiindex_timeseries_df(df_time_series_num_nonan):
-    return df_time_series_num_nonan
+def multiindex_timeseries_df():
+    df_t = pd.DataFrame(
+        {
+            "sample_idx": ["a", "a", "a", "a", "b", "b", "c"],
+            "time_idx": [1, 2, 3, 4, 2, 4, 9],
+            "feat_1": [11, 12, 13, 14, 21, 22, 31],
+            "feat_2": [1.1, 1.2, 1.3, 1.4, 2.1, 2.2, 3.1],
+        }
+    )
+    df_t.set_index(keys=["sample_idx", "time_idx"], drop=True, inplace=True)
+    return df_t
 
 
-class TestMultiindexTimeseriesDfToArray:
-    @pytest.mark.parametrize("padding_indicator", [999.0, np.nan])
+PAD = 999.0
+
+
+class TestMultiindexTimeseriesDataframeToArray3d:
+    @pytest.mark.parametrize("padding_indicator", [PAD, np.nan])
     def test_success(self, multiindex_timeseries_df, padding_indicator):
         pad = padding_indicator
-        array = utils.multiindex_timeseries_df_to_array(multiindex_timeseries_df, padding_indicator=pad)
+        array = utils.multiindex_timeseries_dataframe_to_array3d(multiindex_timeseries_df, padding_indicator=pad)
         assert isinstance(array, np.ndarray)
         assert array.shape == (3, 4, 2)
         assert (
@@ -23,7 +39,7 @@ def test_success(self, multiindex_timeseries_df, padding_indicator):
                 array
                 == np.transpose(
                     np.asarray(
-                        [
+                        [  # pyright: ignore
                             [
                                 [11, 12, 13, 14],
                                 [1.1, 1.2, 1.3, 1.4],
@@ -47,11 +63,14 @@ def test_success(self, multiindex_timeseries_df, padding_indicator):
     @pytest.mark.parametrize("padding_indicator", [999.0, np.nan])
     def test_success_extend(self, multiindex_timeseries_df, padding_indicator):
         pad = padding_indicator
-        array = utils.multiindex_timeseries_df_to_array(
+        array = utils.multiindex_timeseries_dataframe_to_array3d(
             multiindex_timeseries_df, padding_indicator=pad, max_timesteps=5
         )
         assert isinstance(array, np.ndarray)
         assert array.shape == (3, 5, 2)
+
+        # Check, element-wise, that `array` element is equal to the expected array element, or (in case padding is a
+        # `nan`) that the `array` has a `nan` at the element location.
         assert (
             np.logical_xor(
                 array
@@ -81,17 +100,20 @@ def test_success_extend(self, multiindex_timeseries_df, padding_indicator):
     @pytest.mark.parametrize("padding_indicator", [999.0, np.nan])
     def test_success_shrink(self, multiindex_timeseries_df, padding_indicator):
         pad = padding_indicator
-        array = utils.multiindex_timeseries_df_to_array(
+        array = utils.multiindex_timeseries_dataframe_to_array3d(
             multiindex_timeseries_df, padding_indicator=pad, max_timesteps=3
         )
         assert isinstance(array, np.ndarray)
         assert array.shape == (3, 3, 2)
+
+        # Check, element-wise, that `array` element is equal to the expected array element, or (in case padding is a
+        # `nan`) that the `array` has a `nan` at the element location.
         assert (
             np.logical_xor(
                 array
                 == np.transpose(
                     np.asarray(
-                        [
+                        [  # pyright: ignore
                             [
                                 [11, 12, 13],
                                 [1.1, 1.2, 1.3],
@@ -116,4 +138,808 @@ def test_success_shrink(self, multiindex_timeseries_df, padding_indicator):
     def test_fails_padding_found(self, multiindex_timeseries_df, padding_indicator):
         multiindex_timeseries_df.loc[("a", 2), "f1"] = padding_indicator
         with pytest.raises(ValueError, match=".*padding.*"):
-            utils.multiindex_timeseries_df_to_array(multiindex_timeseries_df, padding_indicator=padding_indicator)
+            utils.multiindex_timeseries_dataframe_to_array3d(
+                multiindex_timeseries_df, padding_indicator=padding_indicator
+            )
+
+
+class TestCheckBoolArray1dTruesConsecutive:
+    @pytest.mark.parametrize(
+        "array,match_exc",
+        [
+            (np.ones(shape=(2, 3)).astype(bool), ".*1d.*"),
+            (np.asarray([1, 2, 3]), ".*bool.*"),
+        ],
+    )
+    def test_input_validation_exception(self, array: np.ndarray, match_exc: str):
+        with pytest.raises(ValueError, match=match_exc):
+            utils.check_bool_array1d_trues_consecutive(array)
+
+    @pytest.mark.parametrize(
+        "array,expected",
+        [
+            (np.asarray([False, True, True, True, False]), True),
+            (np.asarray([False, True, False]), True),
+            (np.asarray([False, True, True, False, True, True, False]), False),
+            (np.asarray([False, True, False, True, False]), False),
+        ],
+    )
+    def test_base_case(self, array: np.ndarray, expected: bool):
+        assert utils.check_bool_array1d_trues_consecutive(array) == expected
+
+    @pytest.mark.parametrize(
+        "array,expected",
+        [
+            (np.asarray([], dtype=bool), True),  # Empty array.
+            (np.asarray([False, False, False]), True),  # No True.
+            (np.asarray([True]), True),  # Single element, True.
+            (np.asarray([False]), True),  # Single element, False.
+        ],
+    )
+    def test_corner_case_arrays(self, array: np.ndarray, expected: bool):
+        assert utils.check_bool_array1d_trues_consecutive(array) == expected
+
+    @pytest.mark.parametrize(
+        "array,expected",
+        [
+            (np.asarray([False, True, True, True, False]), False),
+            (np.asarray([True, True, True, True, False]), True),
+            (np.asarray([True, True, True, False, True, True, False]), False),
+            (np.asarray([True]), True),
+            (np.asarray([False]), True),  # Expected.
+        ],
+    )
+    def test_with_at_beginning(self, array: np.ndarray, expected: bool):
+        assert utils.check_bool_array1d_trues_consecutive(array, at_beginning=True) == expected
+
+    @pytest.mark.parametrize(
+        "array,expected",
+        [
+            (np.asarray([False, True, True, True, False]), False),
+            (np.asarray([False, True, True, True, True]), True),
+            (np.asarray([False, True, True, False, True, True, True]), False),
+            (np.asarray([True]), True),
+            (np.asarray([False]), True),  # Expected.
+        ],
+    )
+    def test_with_at_end(self, array: np.ndarray, expected: bool):
+        assert utils.check_bool_array1d_trues_consecutive(array, at_end=True) == expected
+
+    @pytest.mark.parametrize(
+        "array,expected",
+        [
+            (np.asarray([True, True, True]), True),
+            (np.asarray([True, False, True]), False),
+        ],
+    )
+    def test_with_at_beginning_and_end(self, array: np.ndarray, expected: bool):
+        assert utils.check_bool_array1d_trues_consecutive(array, at_beginning=True, at_end=True) == expected
+
+
+class TestCheckBoolArray2dIdenticalAlongDim1:
+    @pytest.mark.parametrize(
+        "array,match_exc",
+        [
+            (np.ones(shape=(2,)).astype(bool), ".*2d.*"),
+            (np.ones(shape=(2, 3, 2)).astype(bool), ".*2d.*"),
+            (np.asarray([[1, 9, 2], [2, 3, 3]]).T, ".*bool.*"),
+        ],
+    )
+    def test_input_validation_exception(self, array: np.ndarray, match_exc: str):
+        with pytest.raises(ValueError, match=match_exc):
+            utils.check_bool_array2d_identical_along_dim1(array)
+
+    @pytest.mark.parametrize(
+        "array,expected",
+        [
+            (np.asarray([[True, True, False], [True, True, False]]).T, True),
+            (np.asarray([[True, True, False], [True, False, False]]).T, False),
+            (np.asarray([[True, True, False], [True, True, False], [True, True, False]]).T, True),
+            (np.asarray([[True, True, False], [True, False, False], [True, False, True]]).T, False),
+        ],
+    )
+    def test_base_case(self, array: np.ndarray, expected: bool):
+        assert utils.check_bool_array2d_identical_along_dim1(array) == expected
+
+    @pytest.mark.parametrize(
+        "array,expected",
+        [
+            (np.asarray([[], []], dtype=bool), True),  # Empty array.
+            (np.asarray([[False, True, False]]).T, True),  # Only one long along dim 1.
+            (np.asarray([[False]]).T, True),  # Only one long along dim 1 & one element.
+            (np.asarray([[False], [False]]).T, True),  # One element per, False.
+            (np.asarray([[True], [True]]).T, True),  # One element per, False.
+            (np.asarray([[True], [False]]).T, False),  # One element per, mixed.
+        ],
+    )
+    def test_corner_case_arrays(self, array: np.ndarray, expected: bool):
+        assert utils.check_bool_array2d_identical_along_dim1(array) == expected
+
+
+class TestGetArray1dLengthUntilPadding:
+    @pytest.mark.parametrize(
+        "array,padding_indicator,match_exc",
+        [
+            (
+                np.random.randint(0, 5, size=(2, 3, 2)),
+                PAD,
+                ".*1d.*",
+            ),
+            (
+                np.asarray([1.5, 2.5, np.nan]),
+                np.nan,
+                ".*[Pp]adding.*nan.*",
+            ),
+        ],
+    )
+    def test_input_validation_exception(self, array: np.ndarray, padding_indicator: Any, match_exc: str):
+        with pytest.raises(ValueError, match=match_exc):
+            utils.get_array1d_length_until_padding(array, padding_indicator=padding_indicator)
+
+    @pytest.mark.parametrize(
+        "array,expected",
+        [
+            (np.asarray([1, 8, -3, PAD, PAD]), 3),
+            (np.asarray([1, 8, -3, 9, PAD]), 4),
+            (np.asarray([1, 8, -3]), 3),
+        ],
+    )
+    def test_base_case(self, array: np.ndarray, expected: int):
+        length = utils.get_array1d_length_until_padding(array, padding_indicator=PAD)
+        assert length == expected
+
+    @pytest.mark.parametrize(
+        "array,expected",
+        [
+            (np.asarray([]), 0),  # Empty.
+            (np.asarray([PAD, PAD, PAD]), 0),  # All padding.
+            (np.asarray([1]), 1),  # Single element, not padding.
+            (np.asarray([PAD]), 0),  # Single element, padding.
+        ],
+    )
+    def test_corner_case_arrays(self, array: np.ndarray, expected: int):
+        length = utils.get_array1d_length_until_padding(array, padding_indicator=PAD)
+        assert length == expected
+
+
+class TestValidateTimeseriesArray3d:
+    @pytest.mark.parametrize(
+        "array,padding_indicator",
+        [
+            (np.ones(shape=(3, 5, 2)), None),
+            (np.ones(shape=(3, 5, 2)), PAD),
+        ],
+    )
+    def test_passes(self, array: np.ndarray, padding_indicator):
+        utils.validate_timeseries_array3d(array, padding_indicator=padding_indicator)
+
+    @pytest.mark.parametrize(
+        "array,padding_indicator",
+        [
+            (np.ones(shape=(3, 5, 0)), None),
+            (np.ones(shape=(3, 5, 0)), PAD),
+        ],
+    )
+    def test_fails_0_feature_dim(self, array: np.ndarray, padding_indicator):
+        with pytest.raises(ValueError, match=".*feature dim.*"):
+            utils.validate_timeseries_array3d(array, padding_indicator=padding_indicator)
+
+    @pytest.mark.parametrize("padding_indicator", [PAD, None])
+    @pytest.mark.parametrize("array", [np.ones(shape=(1,)), np.ones(shape=(3, 5)), np.ones(shape=(3, 5, 2, 8))])
+    def test_fails_not_3dim(self, array: np.ndarray, padding_indicator):
+        with pytest.raises(ValueError, match="3d"):
+            utils.validate_timeseries_array3d(array, padding_indicator=padding_indicator)
+
+    def test_fails_unsupported_padding(self):
+        array = np.ones(shape=(3, 5, 2))
+        padding_indicator = np.nan
+        with pytest.raises(ValueError, match=".*[Pp]adding.*nan.*"):
+            utils.validate_timeseries_array3d(array, padding_indicator=padding_indicator)
+
+
+class TestGetSeqLengthsTimeseriesArray3d:
+    @pytest.mark.parametrize(
+        "array,expected",
+        [
+            (np.ones(shape=(3, 5, 2)), [5, 5, 5]),
+            (np.ones(shape=(4, 2, 1)), [2, 2, 2, 2]),
+            (np.ones(shape=(3, 0, 1)), [0, 0, 0]),  # Edge case: zero timesteps.
+        ],
+    )
+    def test_padding_not_provided_case(self, array: np.ndarray, expected: List[int], monkeypatch):
+        mock_validate_timeseries_array3d = Mock(side_effect=utils.validate_timeseries_array3d)
+        monkeypatch.setattr(utils, "validate_timeseries_array3d", mock_validate_timeseries_array3d)
+        # ^ To confirm input array validation function is always called.
+        # Also call it as side effect, to make sure it doesn't raise exceptions in these cases.
+
+        lengths = utils.get_seq_lengths_timeseries_array3d(array, padding_indicator=None)
+
+        mock_validate_timeseries_array3d.assert_called_once()
+        assert lengths == expected
+
+    @pytest.mark.parametrize(
+        "array,padding_indicator,expected",
+        [
+            # Typical case:
+            (
+                np.transpose(
+                    np.asarray(
+                        [  # pyright: ignore
+                            [
+                                [11, 12, 13, 14, PAD],
+                                [1.1, 1.2, 1.3, 1.4, PAD],
+                            ],
+                            [
+                                [21, 22, PAD, PAD, PAD],
+                                [2.1, 2.2, PAD, PAD, PAD],
+                            ],
+                            [
+                                [31, PAD, PAD, PAD, PAD],
+                                [3.1, PAD, PAD, PAD, PAD],
+                            ],
+                        ]
+                    ),
+                    (0, 2, 1),
+                ),
+                PAD,
+                [4, 2, 1],
+            ),
+            # No padding case:
+            (
+                np.transpose(
+                    np.asarray(
+                        [  # pyright: ignore
+                            [
+                                [11, 12, 13],
+                                [1.1, 1.2, 1.3],
+                            ],
+                            [
+                                [21, 22, 23],
+                                [2.1, 2.2, 2.3],
+                            ],
+                        ]
+                    ),
+                    (0, 2, 1),
+                ),
+                PAD,
+                [3, 3],
+            ),
+            # All padding case:
+            (
+                np.transpose(
+                    np.asarray(
+                        [  # pyright: ignore
+                            [
+                                [PAD, PAD, PAD, PAD],
+                                [PAD, PAD, PAD, PAD],
+                            ],
+                            [
+                                [PAD, PAD, PAD, PAD],
+                                [PAD, PAD, PAD, PAD],
+                            ],
+                        ]
+                    ),
+                    (0, 2, 1),
+                ),
+                PAD,
+                [0, 0],
+            ),
+            # Single sample case:
+            (
+                np.transpose(
+                    np.asarray(
+                        [
+                            [  # pyright: ignore
+                                [21, 22, PAD, PAD],
+                                [2.1, 2.2, PAD, PAD],
+                            ],
+                        ]
+                    ),
+                    (0, 2, 1),
+                ),
+                PAD,
+                [2],
+            ),
+        ],
+    )
+    def test_padding_provided_case_success(
+        self, array: np.ndarray, padding_indicator: Any, expected: List[int], monkeypatch
+    ):
+        mock_validate_timeseries_array3d = Mock()
+        monkeypatch.setattr(utils, "validate_timeseries_array3d", mock_validate_timeseries_array3d)
+
+        lengths = utils.get_seq_lengths_timeseries_array3d(array, padding_indicator=padding_indicator)
+
+        mock_validate_timeseries_array3d.assert_called_once()
+        assert lengths == expected
+
+    @pytest.mark.parametrize(
+        "array,padding_indicator,expected",
+        [
+            # No samples case:
+            (
+                np.ones(shape=(0, 5, 3)),
+                PAD,
+                [],
+            ),
+            # No timesteps case:
+            (
+                np.ones(shape=(4, 0, 3)),
+                PAD,
+                [0, 0, 0, 0],
+            ),
+            # NOTE: No features case will raise an exception, see TestValidateTimeseriesArray3d.
+        ],
+    )
+    def test_padding_provided_case_success_edge_cases(
+        self, array: np.ndarray, padding_indicator: Any, expected: List[int], monkeypatch
+    ):
+        mock_validate_timeseries_array3d = Mock()
+        monkeypatch.setattr(utils, "validate_timeseries_array3d", mock_validate_timeseries_array3d)
+
+        lengths = utils.get_seq_lengths_timeseries_array3d(array, padding_indicator=padding_indicator)
+
+        mock_validate_timeseries_array3d.assert_called_once()
+        assert lengths == expected
+
+    @pytest.mark.parametrize(
+        "array,padding_indicator,match_exc",
+        [
+            # Case: padding indicated differently between features.
+            (
+                np.transpose(
+                    np.asarray(
+                        [
+                            [  # pyright: ignore
+                                [11, 12, 13, PAD, PAD],
+                                [1.1, 1.2, 1.3, 1.4, PAD],
+                            ],
+                        ]
+                    ),
+                    (0, 2, 1),
+                ),
+                PAD,
+                ".*indicate.*",
+            ),
+            # Case: padding not correctly formatted.
+            (
+                np.transpose(
+                    np.asarray(
+                        [
+                            [  # pyright: ignore
+                                [11, 12, PAD, 14, PAD],
+                                [1.1, 1.2, PAD, 1.4, PAD],
+                            ],
+                        ]
+                    ),
+                    (0, 2, 1),
+                ),
+                PAD,
+                ".*consecutive.*end.*",
+            ),
+        ],
+    )
+    def test_padding_provided_case_fails_padding_not_defined_correctly(
+        self, array: np.ndarray, padding_indicator: Any, match_exc: str
+    ):
+        with pytest.raises(ValueError, match=match_exc):
+            utils.get_seq_lengths_timeseries_array3d(array, padding_indicator=padding_indicator)
+
+
+class TestUnpadTimeseriesArray3d:
+    @pytest.mark.parametrize(
+        "array,padding_indicator,expected",
+        [
+            # Typical case:
+            (
+                np.transpose(
+                    np.asarray(
+                        [  # pyright: ignore
+                            [
+                                [11, 12, 13, 14, PAD],
+                                [1.1, 1.2, 1.3, 1.4, PAD],
+                            ],
+                            [
+                                [21, 22, PAD, PAD, PAD],
+                                [2.1, 2.2, PAD, PAD, PAD],
+                            ],
+                            [
+                                [31, PAD, PAD, PAD, PAD],
+                                [3.1, PAD, PAD, PAD, PAD],
+                            ],
+                        ]
+                    ),
+                    (0, 2, 1),
+                ),
+                PAD,
+                [
+                    np.asarray(
+                        [
+                            [11, 12, 13, 14],
+                            [1.1, 1.2, 1.3, 1.4],
+                        ]
+                    ).T,
+                    np.asarray(
+                        [
+                            [21, 22],
+                            [2.1, 2.2],
+                        ]
+                    ).T,
+                    np.asarray(
+                        [
+                            [31],
+                            [3.1],
+                        ]
+                    ).T,
+                ],
+            ),
+            # No padding case:
+            (
+                np.transpose(
+                    np.asarray(
+                        [  # pyright: ignore
+                            [
+                                [11, 12, 13],
+                                [1.1, 1.2, 1.3],
+                            ],
+                            [
+                                [21, 22, 23],
+                                [2.1, 2.2, 2.3],
+                            ],
+                        ]
+                    ),
+                    (0, 2, 1),
+                ),
+                PAD,
+                [
+                    np.asarray(
+                        [
+                            [11, 12, 13],
+                            [1.1, 1.2, 1.3],
+                        ]
+                    ).T,
+                    np.asarray(
+                        [
+                            [21, 22, 23],
+                            [2.1, 2.2, 2.3],
+                        ]
+                    ).T,
+                ],
+            ),
+            # All padding case:
+            (
+                np.transpose(
+                    np.asarray(
+                        [  # pyright: ignore
+                            [
+                                [PAD, PAD, PAD, PAD],
+                                [PAD, PAD, PAD, PAD],
+                            ],
+                            [
+                                [PAD, PAD, PAD, PAD],
+                                [PAD, PAD, PAD, PAD],
+                            ],
+                        ]
+                    ),
+                    (0, 2, 1),
+                ),
+                PAD,
+                [
+                    np.ones(shape=(0, 2), dtype=float),
+                    np.ones(shape=(0, 2), dtype=float),
+                ],
+            ),
+            # Single sample case:
+            (
+                np.transpose(
+                    np.asarray(
+                        [
+                            [  # pyright: ignore
+                                [21, 22, PAD, PAD],
+                                [2.1, 2.2, PAD, PAD],
+                            ],
+                        ]
+                    ),
+                    (0, 2, 1),
+                ),
+                PAD,
+                [
+                    np.asarray(
+                        [
+                            [21, 22],
+                            [2.1, 2.2],
+                        ]
+                    ).T,
+                ],
+            ),
+        ],
+    )
+    def test_success(self, array: np.ndarray, padding_indicator: Any, expected: List[np.ndarray], monkeypatch):
+        mock_validate_timeseries_array3d = Mock()
+        monkeypatch.setattr(utils, "validate_timeseries_array3d", mock_validate_timeseries_array3d)
+
+        unpadded = utils.unpad_timeseries_array3d(array, padding_indicator=padding_indicator)
+
+        mock_validate_timeseries_array3d.assert_called()
+        assert len(unpadded) == len(expected)
+        for sample_i in range(len(expected)):
+            assert (unpadded[sample_i] == expected[sample_i]).all()
+
+    @pytest.mark.parametrize(
+        "array,padding_indicator,expected",
+        [
+            # No samples case:
+            (
+                np.ones(shape=(0, 5, 3)),
+                PAD,
+                [],
+            ),
+            # No timesteps case:
+            (
+                np.ones(shape=(4, 0, 3)),
+                PAD,
+                [
+                    np.ones(shape=(0, 3), dtype=float),
+                    np.ones(shape=(0, 3), dtype=float),
+                    np.ones(shape=(0, 3), dtype=float),
+                    np.ones(shape=(0, 3), dtype=float),
+                ],
+            ),
+            # NOTE: No features case will raise an exception, see TestValidateTimeseriesArray3d.
+        ],
+    )
+    def test_success_edge_cases(
+        self, array: np.ndarray, padding_indicator: Any, expected: List[np.ndarray], monkeypatch
+    ):
+        mock_validate_timeseries_array3d = Mock()
+        monkeypatch.setattr(utils, "validate_timeseries_array3d", mock_validate_timeseries_array3d)
+
+        unpadded = utils.unpad_timeseries_array3d(array, padding_indicator=padding_indicator)
+
+        mock_validate_timeseries_array3d.assert_called()
+        assert len(unpadded) == len(expected)
+        for sample_i in range(len(expected)):
+            assert (unpadded[sample_i] == expected[sample_i]).all()
+
+
+class TestMakeSampleTimeIndexTuples:
+    @pytest.mark.parametrize(
+        "sample_index,time_indexes,expected",
+        [
+            # Typical case:
+            (
+                ["s1", "s2"],
+                [[1, 2, 3], [1, 5, 9, 10]],
+                [("s1", 1), ("s1", 2), ("s1", 3), ("s2", 1), ("s2", 5), ("s2", 9), ("s2", 10)],
+            ),
+            # Single time indexes case:
+            (
+                ["s1"],
+                [[]],
+                [],
+            ),
+            # Single item case:
+            (
+                ["s1"],
+                [[1]],
+                [("s1", 1)],
+            ),
+            # Empty lists case:
+            (
+                [],
+                [],
+                [],
+            ),
+        ],
+    )
+    def test_success(self, sample_index: List, time_indexes: List[List], expected: List[Tuple]):
+        assert utils.make_sample_time_index_tuples(sample_index, time_indexes) == expected
+
+    def test_fails_len_mismatch(self):
+        sample_index = ["s1", "s2"]
+        time_indexes = [[1, 2, 3]]
+        with pytest.raises(ValueError, match=".*elements.*"):
+            utils.make_sample_time_index_tuples(sample_index, time_indexes)  # pyright: ignore
+
+
+class TestArray3dToMultiindexTimeseriesDataframe:
+    @pytest.mark.parametrize(
+        "array,sample_index,time_indexes,expected",
+        [
+            # Typical case:
+            (
+                # array:
+                np.transpose(
+                    np.asarray(
+                        [  # pyright: ignore
+                            [
+                                [11, 12, 13],
+                                [1.1, 1.2, 1.3],
+                            ],
+                            [
+                                [21, 22, 23],
+                                [2.1, 2.2, 2.3],
+                            ],
+                        ]
+                    ),
+                    (0, 2, 1),
+                ),
+                # sample_index:
+                ["s1", "s2"],
+                # time_indexes:
+                [[1, 2, 3], [10, 11, 15]],
+                # expected:
+                pd.DataFrame(
+                    {"feat_0": [11.0, 12.0, 13.0, 21.0, 22.0, 23.0], "feat_1": [1.1, 1.2, 1.3, 2.1, 2.2, 2.3]},
+                    index=pd.MultiIndex.from_tuples(
+                        [("s1", 1), ("s1", 2), ("s1", 3), ("s2", 10), ("s2", 11), ("s2", 15)]
+                    ),
+                ),
+            ),
+            # Single sample case:
+            (
+                # array:
+                np.transpose(
+                    np.asarray(
+                        [
+                            [  # pyright: ignore
+                                [11, 12],
+                                [1.1, 1.2],
+                            ],
+                        ]
+                    ),
+                    (0, 2, 1),
+                ),
+                # sample_index:
+                ["s1"],
+                # time_indexes:
+                [[1, 100]],
+                # expected:
+                pd.DataFrame(
+                    {"feat_0": [11.0, 12.0], "feat_1": [1.1, 1.2]},
+                    index=pd.MultiIndex.from_tuples([("s1", 1), ("s1", 100)]),
+                ),
+            ),
+        ],
+    )
+    def test_success_no_padding(
+        self,
+        array: np.ndarray,
+        sample_index: List,
+        time_indexes: List[List],
+        expected: pd.DataFrame,
+        monkeypatch,
+    ):
+        mock_validate_timeseries_array3d = Mock()
+        monkeypatch.setattr(utils, "validate_timeseries_array3d", mock_validate_timeseries_array3d)
+        feature_index = [f"feat_{x}" for x in range(array.shape[2])]
+
+        df = utils.array3d_to_multiindex_timeseries_dataframe(
+            array,
+            sample_index=sample_index,
+            time_indexes=time_indexes,
+            feature_index=feature_index,
+            padding_indicator=None,
+        )
+
+        mock_validate_timeseries_array3d.assert_called()
+        print(df)
+        print(expected)
+        assert df.equals(expected)
+
+    @pytest.mark.parametrize(
+        "array,sample_index,time_indexes,padding_indicator,expected",
+        [
+            # Typical case:
+            (
+                # array:
+                np.transpose(
+                    np.asarray(
+                        [  # pyright: ignore
+                            [
+                                [11, 12, 13, 14, PAD],
+                                [1.1, 1.2, 1.3, 1.4, PAD],
+                            ],
+                            [
+                                [21, 22, PAD, PAD, PAD],
+                                [2.1, 2.2, PAD, PAD, PAD],
+                            ],
+                        ]
+                    ),
+                    (0, 2, 1),
+                ),
+                # sample_index:
+                ["s1", "s2"],
+                # time_indexes:
+                [[1, 2, 3, 4], [10, 20]],
+                # padding_indicator:
+                PAD,
+                # expected:
+                pd.DataFrame(
+                    {"feat_0": [11.0, 12.0, 13.0, 14.0, 21.0, 22.0], "feat_1": [1.1, 1.2, 1.3, 1.4, 2.1, 2.2]},
+                    index=pd.MultiIndex.from_tuples(
+                        [("s1", 1), ("s1", 2), ("s1", 3), ("s1", 4), ("s2", 10), ("s2", 20)]
+                    ),
+                ),
+            ),
+            # Single sample case:
+            (
+                # array:
+                np.transpose(
+                    np.asarray(
+                        [
+                            [  # pyright: ignore
+                                [21, 22, PAD, PAD, PAD],
+                                [2.1, 2.2, PAD, PAD, PAD],
+                            ],
+                        ]
+                    ),
+                    (0, 2, 1),
+                ),
+                # sample_index:
+                ["s1"],
+                # time_indexes:
+                [[1, 2]],
+                # padding_indicator:
+                PAD,
+                # expected:
+                pd.DataFrame(
+                    {"feat_0": [21.0, 22.0], "feat_1": [2.1, 2.2]},
+                    index=pd.MultiIndex.from_tuples([("s1", 1), ("s1", 2)]),
+                ),
+            ),
+            # All padding sample case:
+            (
+                # array:
+                np.transpose(
+                    np.asarray(
+                        [  # pyright: ignore
+                            [
+                                [11, 12, PAD],
+                                [1.1, 1.2, PAD],
+                            ],
+                            [
+                                [PAD, PAD, PAD],
+                                [PAD, PAD, PAD],
+                            ],
+                        ]
+                    ),
+                    (0, 2, 1),
+                ),
+                # sample_index:
+                ["s1", "s2"],
+                # time_indexes:
+                [[1, 2], []],
+                # padding_indicator:
+                PAD,
+                # expected:
+                pd.DataFrame(
+                    {"feat_0": [11.0, 12.0], "feat_1": [1.1, 1.2]},
+                    index=pd.MultiIndex.from_tuples([("s1", 1), ("s1", 2)]),
+                ),
+            ),
+        ],
+    )
+    def test_success_with_padding(
+        self,
+        array: np.ndarray,
+        sample_index: List,
+        time_indexes: List[List],
+        padding_indicator: Any,
+        expected: pd.DataFrame,
+        monkeypatch,
+    ):
+        mock_validate_timeseries_array3d = Mock()
+        monkeypatch.setattr(utils, "validate_timeseries_array3d", mock_validate_timeseries_array3d)
+        feature_index = [f"feat_{x}" for x in range(array.shape[2])]
+
+        df = utils.array3d_to_multiindex_timeseries_dataframe(
+            array,
+            sample_index=sample_index,
+            time_indexes=time_indexes,
+            feature_index=feature_index,
+            padding_indicator=padding_indicator,
+        )
+
+        mock_validate_timeseries_array3d.assert_called()
+        assert df.equals(expected)
diff --git a/tests/data/test_validator.py b/tests/data/test_validator.py
deleted file mode 100644
index b806df4e..00000000
--- a/tests/data/test_validator.py
+++ /dev/null
@@ -1,143 +0,0 @@
-# pylint: disable=redefined-outer-name, unused-argument
-
-import enum
-from typing import Dict, Tuple, Union
-from unittest.mock import Mock
-
-import pytest
-
-import tempor.data._check_data_container_def as check
-import tempor.data._types as types
-import tempor.data.validator as v
-
-MockCategoryA = Mock()
-
-
-@pytest.fixture
-def dummy_implementation():
-    return Mock()
-
-
-class DummyValImpl:
-    # Dummy ValidationImplementation class.
-    pass
-
-
-class DummyContainerA:
-    pass
-
-
-class DummyContainerB:
-    pass
-
-
-DummyContainerType = Union[DummyContainerA, DummyContainerB]
-
-
-class DummyContainerFlavor(enum.Enum):
-    CONT_A_1 = enum.auto()
-    CONT_A_2 = enum.auto()
-    CONT_B_1 = enum.auto()
-
-
-mock_check = Mock()
-
-DummyDef = Tuple[type, DummyContainerFlavor]
-
-
-@pytest.fixture
-def set_dummy_container_types(monkeypatch):
-    monkeypatch.setattr(types, "DataContainer", DummyContainerType)
-
-
-@pytest.fixture
-def set_dummy_check(monkeypatch):
-    monkeypatch.setattr(check, "CheckDataContainerDefinition", mock_check)
-
-
-def test_validator_init_success(dummy_implementation, set_dummy_container_types, set_dummy_check):
-    class TestDataValidator(v.DataValidator):
-        @property
-        def data_category(self):
-            return MockCategoryA
-
-        @property
-        def supports_implementations_for(self) -> Tuple[DummyDef, ...]:
-            return ((DummyContainerA, DummyContainerFlavor.CONT_A_1), (DummyContainerB, DummyContainerFlavor.CONT_B_1))
-
-        def _register_implementations(self) -> Dict[DummyDef, DummyValImpl]:
-            return {
-                (DummyContainerA, DummyContainerFlavor.CONT_A_1): dummy_implementation,
-                (DummyContainerB, DummyContainerFlavor.CONT_B_1): dummy_implementation,
-            }
-
-    _ = TestDataValidator()
-
-    mock_check.assert_any_call(  # Non-last call.
-        data_category=MockCategoryA, container_class=DummyContainerA, container_flavor=DummyContainerFlavor.CONT_A_1
-    )
-    mock_check.assert_called_with(  # Last call.
-        data_category=MockCategoryA, container_class=DummyContainerB, container_flavor=DummyContainerFlavor.CONT_B_1
-    )
-
-
-def test_validator_validate_success(dummy_implementation, set_dummy_container_types, set_dummy_check):
-    class TestDataValidator(v.DataValidator):
-        @property
-        def data_category(self):
-            return MockCategoryA
-
-        @property
-        def supports_implementations_for(self) -> Tuple[DummyDef, ...]:
-            return ((DummyContainerA, DummyContainerFlavor.CONT_A_1), (DummyContainerB, DummyContainerFlavor.CONT_B_1))
-
-        def _register_implementations(self) -> Dict[DummyDef, DummyValImpl]:
-            return {
-                (DummyContainerA, DummyContainerFlavor.CONT_A_1): dummy_implementation,
-                (DummyContainerB, DummyContainerFlavor.CONT_B_1): dummy_implementation,
-            }
-
-    validator = TestDataValidator()
-    validator.validate(
-        data=DummyContainerA(),  # pyright: ignore
-        requirements=[],
-        container_flavor=DummyContainerFlavor.CONT_A_1,  # pyright: ignore
-    )
-    validator.validate(
-        data=DummyContainerB(),  # pyright: ignore
-        requirements=[],
-        container_flavor=DummyContainerFlavor.CONT_B_1,  # pyright: ignore
-    )
-
-
-def test_validator_validate_fails_not_data_container_type(
-    dummy_implementation, set_dummy_container_types, set_dummy_check
-):
-    class TestDataValidator(v.DataValidator):
-        @property
-        def data_category(self):
-            return MockCategoryA
-
-        @property
-        def supports_implementations_for(self) -> Tuple[DummyDef, ...]:
-            return ((DummyContainerA, DummyContainerFlavor.CONT_A_1),)
-
-        def _register_implementations(self) -> Dict[DummyDef, DummyValImpl]:
-            return {
-                (DummyContainerA, DummyContainerFlavor.CONT_A_1): dummy_implementation,
-            }
-
-    validator = TestDataValidator()
-
-    with pytest.raises(TypeError, match=".supported types.*"):
-        validator.validate(
-            data="string_is_wrong_type",  # pyright: ignore
-            requirements=[],
-            container_flavor=DummyContainerFlavor.CONT_A_1,  # pyright: ignore
-        )
-
-
-def test_instantiation_success():
-    _ = v.StaticDataValidator()
-    _ = v.TimeSeriesDataValidator()
-    _ = v.EventDataValidator()
diff --git a/tests/data/test_validator_impl.py b/tests/data/test_validator_impl.py
deleted file mode 100644
index 2f2b1e35..00000000
--- a/tests/data/test_validator_impl.py
+++ /dev/null
@@ -1,157 +0,0 @@
-# pylint: disable=redefined-outer-name, unused-argument
-
-from unittest.mock import Mock
-
-import pytest
-
-import tempor.data.requirements as r
-import tempor.data.validator.impl as vi
-import tempor.exc
-
-MockCategoryX = Mock()
-MockCategoryY = Mock()
-
-
-class DummyRequirement:
-    pass
-
-
-class DummyRequirementA(DummyRequirement):
-    pass
-
-
-class DummyRequirementB(DummyRequirement):
-    pass
-
-
-class DummyRequirementC(DummyRequirement):
-    pass
-
-
-@pytest.fixture
-def register_dummy_requirements(monkeypatch):
-    dummies = {
-        MockCategoryX: {DummyRequirementA, DummyRequirementB},
-        MockCategoryY: {DummyRequirementB, DummyRequirementC},
-    }
-    monkeypatch.setattr(r, "DATA_REQUIREMENTS", dummies)
-
-
-@pytest.mark.parametrize(
-    "validation_methods_dict",
-    [
-        dict(),
-        {
-            DummyRequirementC: Mock(),
-        },
-        {
-            DummyRequirementA: Mock(),
-            DummyRequirementC: Mock(),
-        },
-    ],
-)
-def test_validation_implementation_wrong_methods(validation_methods_dict, register_dummy_requirements):
-    with pytest.raises(TypeError, match=".*data requirements.*"):
-
-        class TestValImpl(vi.ValidatorImplementation):
-            @property
-            def data_category(self):
-                return MockCategoryX
-
-            validation_methods = validation_methods_dict
-
-            def root_validate(self, data):
-                return data
-
-        _ = TestValImpl()
-
-
-def test_validation_implementation_wrong_methods_via_decorator(register_dummy_requirements):
-    with pytest.raises(TypeError, match=".*data requirements.*"):
-
-        class TestValImpl(vi.ValidatorImplementation):
-            @property
-            def data_category(self):
-                return MockCategoryX
-
-            @vi.RegisterValidation.register_method_for(DummyRequirementC)  # pyright: ignore
-            def _(self, data, req):
-                return data
-
-            def root_validate(self, data):
-                return data
-
-        _ = TestValImpl()
-
-
-def test_validate_method_success(register_dummy_requirements):
-    mock_data = Mock()
-    mock_req_a = DummyRequirementA()
-    mock_req_b1 = DummyRequirementB()
-    mock_req_b2 = DummyRequirementB()
-    mock_root_validate_method = Mock()
-    mock_validate_a_method = Mock()
-    mock_validate_b_method = Mock()
-
-    class TestValImpl(vi.ValidatorImplementation):
-        @property
-        def data_category(self):
-            return MockCategoryX
-
-        def root_validate(self, data):
-            mock_root_validate_method(data=data)
-            return data
-
-        @vi.RegisterValidation.register_method_for(DummyRequirementA)  # pyright: ignore
-        def _(self, data, req):
-            mock_validate_a_method(data=data, req=req)
-            return data
-
-        @vi.RegisterValidation.register_method_for(DummyRequirementB)  # pyright: ignore
-        def _(self, data, req):
-            mock_validate_b_method(data=data, req=req)
-            return data
-
-    val = TestValImpl()
-    val.validate(
-        data=mock_data, requirements=[mock_req_a, mock_req_b1, mock_req_b2], container_flavor=Mock()  # pyright: ignore
-    )
-
-    mock_root_validate_method.assert_called_once()
-    mock_validate_a_method.assert_called_once()
-    mock_validate_b_method.assert_called()
-
-    mock_root_validate_method.assert_called_once_with(data=mock_data)
-    mock_validate_a_method.assert_called_once_with(data=mock_data, req=mock_req_a)
-    mock_validate_b_method.assert_any_call(data=mock_data, req=mock_req_b1)  # Non-last call.
-    mock_validate_b_method.assert_called_with(data=mock_data, req=mock_req_b2)  # Last call.
-
-
-def test_validation_exception(register_dummy_requirements):
-    mock_data = Mock()
-    mock_req_a = DummyRequirementA()
-    mock_root_validate_method = Mock()
-
-    class TestValImpl(vi.ValidatorImplementation):
-        @property
-        def data_category(self):
-            return MockCategoryX
-
-        def root_validate(self, data):
-            mock_root_validate_method(data=data)
-            return data
-
-        @vi.RegisterValidation.register_method_for(DummyRequirementA)  # pyright: ignore
-        def _(self, data, req):
-            raise ValueError("My inner error")
-
-        @vi.RegisterValidation.register_method_for(DummyRequirementB)  # pyright: ignore
-        def _(self, data, req):
-            # Do nothing.
-            return data
-
-    val = TestValImpl()
-
-    with pytest.raises(tempor.exc.DataValidationFailedException) as excinfo:
-        val.validate(data=mock_data, requirements=[mock_req_a], container_flavor=Mock())  # pyright: ignore
-    assert "My inner error" in str(excinfo.getrepr())
diff --git a/tests/data/test_validator_impl_df.py b/tests/data/test_validator_impl_df.py
deleted file mode 100644
index 486d64a7..00000000
--- a/tests/data/test_validator_impl_df.py
+++ /dev/null
@@ -1,92 +0,0 @@
-# pylint: disable=redefined-outer-name, unused-argument
-# TODO: Test more root validation cases.
-
-import pandas as pd
-import pytest
-
-import tempor.data as dat
-import tempor.data.requirements as r
-import tempor.exc
-from tempor.data.validator.impl import df_validator
-
-
-@pytest.fixture
-def df_static(df_static_cat_num_hasnan):
-    return df_static_cat_num_hasnan
-
-
-@pytest.fixture
-def df_time_series(df_time_series_num_nonan):
-    return df_time_series_num_nonan
-
-
-@pytest.fixture
-def df_event(df_event_num_nonan):
-    return df_event_num_nonan
-
-
-def test_static_validator_root_validation(df_static):
-    validator = df_validator.StaticDataValidator()
-    validator.validate(df_static, requirements=[], container_flavor=dat.ContainerFlavor.DF_SAMPLE_X_FEATURE)
-
-
-def test_common_requirements_passes(df_static):
-    validator = df_validator.StaticDataValidator()
-    validator.validate(
-        df_static,
-        requirements=[r.ValueDTypes(value_dtypes=[float, "category"])],
-        container_flavor=dat.ContainerFlavor.DF_SAMPLE_X_FEATURE,
-    )
-    validator.validate(
-        df_static,
-        requirements=[r.AllowMissing(allow_missing=True)],
-        container_flavor=dat.ContainerFlavor.DF_SAMPLE_X_FEATURE,
-    )
-
-
-def test_common_requirements_fails(df_static):
-    validator = df_validator.StaticDataValidator()
-
-    with pytest.raises(tempor.exc.DataValidationFailedException):
-        validator.validate(
-            df_static,
-            requirements=[r.ValueDTypes(value_dtypes=[bool])],
-            container_flavor=dat.ContainerFlavor.DF_SAMPLE_X_FEATURE,
-        )
-
-    with pytest.raises(tempor.exc.DataValidationFailedException):
-        validator.validate(
-            df_static,
-            requirements=[r.AllowMissing(allow_missing=False)],
-            container_flavor=dat.ContainerFlavor.DF_SAMPLE_X_FEATURE,
-        )
-
-
-def test_time_series_validator_root_validation(df_time_series):
-    validator = df_validator.TimeSeriesDataValidator()
-    validator.validate(
-        df_time_series, requirements=[], container_flavor=dat.ContainerFlavor.DF_SAMPLE_TIMESTEP_X_FEATURE
-    )
-
-
-def test_event_validator_root_validation(df_event):
-    validator = df_validator.EventDataValidator()
-    validator.validate(df_event, requirements=[], container_flavor=dat.ContainerFlavor.DF_SAMPLE_TIMESTEP_X_FEATURE)
-
-
-def test_event_validator_root_validation_fails_not_one_to_one_indices():
-    df = pd.DataFrame(
-        {
-            "sample_idx": ["a", "a", "b", "c"],
-            "time_idx": [1, 3, 2, 2],
-            "f1": [True, True, False, True],
-            "f2": [0, 0, 0, 1],
-        }
-    )
-    df = df.set_index(keys=["sample_idx", "time_idx"])
-
-    validator = df_validator.EventDataValidator()
-
-    with pytest.raises(tempor.exc.DataValidationFailedException) as excinfo:
-        validator.validate(df, requirements=[], container_flavor=dat.ContainerFlavor.DF_SAMPLE_TIMESTEP_X_FEATURE)
-    assert "one-to-one" in str(excinfo.getrepr())
diff --git a/tests/plugins/core/test_base_estimator.py b/tests/plugins/core/test_base_estimator.py
new file mode 100644
index 00000000..a774890b
--- /dev/null
+++ b/tests/plugins/core/test_base_estimator.py
@@ -0,0 +1,159 @@
+# pylint: disable=redefined-outer-name, unused-argument
+
+import dataclasses
+import re
+from typing import Any, Tuple
+from unittest.mock import MagicMock, Mock
+
+import pytest
+
+import tempor.plugins.core
+
+
+class TestEstimator:
+    @dataclasses.dataclass
+    class MyModelParams:
+        foo: float = 1.5
+        bar: Tuple[int, ...] = (3, 7, 9)
+        baz: Any = "something"
+
+    @dataclasses.dataclass
+    class MyModelParamsWithNoDefault:
+        baz: Any  # No default
+        foo: float = 1.5
+        bar: Tuple[int, ...] = (3, 7, 9)
+
+    def test_init_success_empty_params_definition_model(self):
+        class MyModel(tempor.plugins.core.BaseEstimator):
+            name = "my_model"
+            category = "my_category"
+
+            @staticmethod
+            def hyperparameter_space(*args: Any, **kwargs: Any):
+                return MagicMock()
+
+            def _fit(self, data, *args, **kwargs):
+                pass
+
+        my_model = MyModel()
+
+        assert my_model.params == dict()
+        assert my_model._fitted is False  # pylint: disable=protected-access
+
+    def test_init_success(self):
+        class MyModel(tempor.plugins.core.BaseEstimator):
+            PARAMS_DEFINITION = self.MyModelParams
+            name = "my_model"
+            category = "my_category"
+
+            @staticmethod
+            def hyperparameter_space(*args: Any, **kwargs: Any):
+                return MagicMock()
+
+            def _fit(self, data, *args, **kwargs):
+                pass
+
+        my_model = MyModel()
+
+        assert my_model.params == dict(foo=1.5, bar=(3, 7, 9), baz="something")
+
+    def test_init_success_params_provided(self):
+        class MyModel(tempor.plugins.core.BaseEstimator):
+            name = "my_model"
+            category = "my_category"
+            PARAMS_DEFINITION = self.MyModelParams
+
+            @staticmethod
+            def hyperparameter_space(*args: Any, **kwargs: Any):
+                return MagicMock()
+
+            def _fit(self, data, *args, **kwargs):
+                pass
+
+        my_model = MyModel(foo=2.2, bar=(8, 9, 10), baz="something_else")
+
+        assert my_model.params == dict(foo=2.2, bar=(8, 9, 10), baz="something_else")
+
+    def test_init_success_no_default_and_provided(self):
+        class MyModel(tempor.plugins.core.BaseEstimator):
+            name = "my_model"
+            category = "my_category"
+
+            PARAMS_DEFINITION = self.MyModelParamsWithNoDefault
+
+            @staticmethod
+            def hyperparameter_space(*args: Any, **kwargs: Any):
+                return MagicMock()
+
+            def _fit(self, data, *args, **kwargs):
+                pass
+
+        my_model = MyModel(baz="provided")  # Does not fail.
+        assert my_model.params == dict(foo=1.5, bar=(3, 7, 9), baz="provided")
+
+    def test_init_fails_no_default_and_not_provided(self):
+        class MyModel(tempor.plugins.core.BaseEstimator):
+            name = "my_model"
+            category = "my_category"
+            PARAMS_DEFINITION = self.MyModelParamsWithNoDefault
+
+            @staticmethod
+            def hyperparameter_space(*args: Any, **kwargs: Any):
+                return MagicMock()
+
+            def _fit(self, data, *args, **kwargs):
+                pass
+
+        with pytest.raises(ValueError) as excinfo:
+            _ = MyModel()  # Fails.
+        assert "missing" in str(excinfo.getrepr())
+
+    def test_init_fails_omegaconf_incompatible_type(self):
+        class MyModel(tempor.plugins.core.BaseEstimator):
+            name = "my_model"
+            category = "my_category"
+            PARAMS_DEFINITION = self.MyModelParams
+
+            @staticmethod
+            def hyperparameter_space(*args: Any, **kwargs: Any):
+                return MagicMock()
+
+            def _fit(self, data, *args, **kwargs):
+                pass
+
+        with pytest.raises(ValueError, match=".*not a supported primitive.*"):
+            _ = MyModel(baz=Mock())
+
+    def test_init_fails_wrong_type(self):
+        class MyModel(tempor.plugins.core.BaseEstimator):
+            name = "my_model"
+            category = "my_category"
+            PARAMS_DEFINITION = self.MyModelParams
+
+            @staticmethod
+            def hyperparameter_space(*args: Any, **kwargs: Any):
+                return MagicMock()
+
+            def _fit(self, data, *args, **kwargs):
+                pass
+
+        with pytest.raises(ValueError, match=".*type 'str'.*"):
+            _ = MyModel(foo="string")
+
+    def test_repr(self):
+        class MyModel(tempor.plugins.core.BaseEstimator):
+            name = "my_model"
+            category = "my_category"
+            PARAMS_DEFINITION = self.MyModelParams
+
+            @staticmethod
+            def hyperparameter_space(*args: Any, **kwargs: Any):
+                return MagicMock()
+
+            def _fit(self, data, *args, **kwargs):
+                pass
+
+        my_model = MyModel()
+        repr_ = repr(my_model)
+
+        assert re.search(r"^MyModel\(.*name='my_model'.*category='my_category'.*params=.?\{.*\}.*\)", repr_, re.S)
diff --git a/tests/plugins/core/test_plugins.py b/tests/plugins/core/test_plugins.py
new file mode 100644
index 00000000..c82d3018
--- /dev/null
+++ b/tests/plugins/core/test_plugins.py
@@ -0,0 +1,268 @@
+# pylint: disable=redefined-outer-name, unused-argument
+
+from typing import Dict, Type
+from unittest.mock import Mock
+
+import pytest
+
+import tempor.plugins.core._plugin as plugin_core
+
+DUMMY_PLUGIN_CATEGORY_REGISTRY: Dict[str, Type[plugin_core.Plugin]] = dict()
+DUMMY_PLUGIN_REGISTRY: Dict[str, Type[plugin_core.Plugin]] = dict()
+
+
+class TestPluginClass:
+    def test_plugin_init_fails_no_name(self):
+        class NamelessPlugin(plugin_core.Plugin):
+            category = "category_was_set"
+
+        with pytest.raises(ValueError, match=".*name.*not set.*"):
+            NamelessPlugin()
+
+    def test_plugin_init_fails_no_category(self):
+        class CategorylessPlugin(plugin_core.Plugin):
+            name = "name_was_set"
+
+        with pytest.raises(ValueError, match=".*category.*not set.*"):
+            CategorylessPlugin()
+
+
+@pytest.fixture
+def patch_plugins_core_module(patch_module):
+    patch_module(
+        main_module=plugin_core,
+        module_vars=[
+            (
+                plugin_core,
+                plugin_core.PLUGIN_CATEGORY_REGISTRY,
+                "PLUGIN_CATEGORY_REGISTRY",
+                DUMMY_PLUGIN_CATEGORY_REGISTRY,
+            ),
+            (
+                plugin_core,
+                plugin_core.PLUGIN_REGISTRY,
+                "PLUGIN_REGISTRY",
+                DUMMY_PLUGIN_REGISTRY,
+            ),
+        ],
+        refresh_pydantic=False,
+    )
+
+
+class TestRegistration:
+    @pytest.fixture(autouse=True)
+    def always_patch_module_in_this_test_class(self, patch_plugins_core_module):
+        pass
+
+    def test_register_plugin_and_category_success(self):
+        plugin_core.register_plugin_category("dummy_category", expected_class=plugin_core.Plugin)
+
+        @plugin_core.register_plugin(name="dummy_plugin", category="dummy_category")
+        class DummyPlugin(plugin_core.Plugin):
+            pass
+
+        plugin = DummyPlugin()
+
+        assert isinstance(plugin, plugin_core.Plugin)
+        assert plugin.name == "dummy_plugin"
+        assert plugin.category == "dummy_category"
+
+        fqn = plugin.fqn()
+
+        assert fqn == "dummy_category.dummy_plugin"
+
+        assert "dummy_category" in plugin_core.PLUGIN_CATEGORY_REGISTRY
+        assert fqn in plugin_core.PLUGIN_REGISTRY
+
+        assert plugin_core.PLUGIN_CATEGORY_REGISTRY["dummy_category"] == plugin_core.Plugin
+        assert plugin_core.PLUGIN_REGISTRY[fqn] == DummyPlugin.cls  # type: ignore  # pylint: disable=no-member
+
+    def test_register_multiple_plugins_and_categories_success(self):
+        plugin_core.register_plugin_category("dummy_category_A", expected_class=plugin_core.Plugin)
+
+        @plugin_core.register_plugin(name="dummy_plugin_A1", category="dummy_category_A")
+        class DummyPluginA1(plugin_core.Plugin):
+            pass
+
+        @plugin_core.register_plugin(name="dummy_plugin_A2", category="dummy_category_A")
+        class DummyPluginA2(plugin_core.Plugin):
+            pass
+
+        plugin_core.register_plugin_category("dummy_category_B", expected_class=plugin_core.Plugin)
+
+        @plugin_core.register_plugin(name="dummy_plugin_B1", category="dummy_category_B")
+        class DummyPluginB1(plugin_core.Plugin):
+            pass
+
+        p_a1 = DummyPluginA1()
+        p_a2 = DummyPluginA2()
+        p_b1 = DummyPluginB1()
+
+        assert plugin_core.PLUGIN_CATEGORY_REGISTRY["dummy_category_A"] == plugin_core.Plugin
+        assert plugin_core.PLUGIN_CATEGORY_REGISTRY["dummy_category_B"] == plugin_core.Plugin
+        assert plugin_core.PLUGIN_REGISTRY[p_a1.fqn()] == DummyPluginA1.cls  # type: ignore  # pylint: disable=no-member
+        assert plugin_core.PLUGIN_REGISTRY[p_a2.fqn()] == DummyPluginA2.cls  # type: ignore  # pylint: disable=no-member
+        assert plugin_core.PLUGIN_REGISTRY[p_b1.fqn()] == DummyPluginB1.cls  # type: ignore  # pylint: disable=no-member
+
+    def test_category_registration_fails_duplicate(self):
+        plugin_core.register_plugin_category("same_category", expected_class=plugin_core.Plugin)
+
+        with pytest.raises(TypeError, match=".*category.*already registered.*"):
+            plugin_core.register_plugin_category("same_category", expected_class=plugin_core.Plugin)
+
+    def test_category_registration_fails_not_plugin(self):
+        class DoesNotInheritPlugin:
+            pass
+
+        with pytest.raises(TypeError, match=".*subclass.*Plugin.*"):
+            plugin_core.register_plugin_category("dummy_category", expected_class=DoesNotInheritPlugin)
+
+    def test_plugin_registration_fails_unknown_category(self):
+        with pytest.raises(TypeError, match=".*category.*not.*registered.*"):
+
+            @plugin_core.register_plugin(name="dummy_plugin", category="category_not_registered")
+            class DummyPlugin(plugin_core.Plugin):  # pylint: disable=unused-variable
+                pass
+
+    def test_plugin_registration_fails_not_plugin(self):
+        plugin_core.register_plugin_category("dummy_category", expected_class=plugin_core.Plugin)
+
+        with pytest.raises(TypeError, match=".*[Ee]xpected.*subclass.*Plugin.*"):
+
+            @plugin_core.register_plugin(name="dummy_plugin", category="dummy_category")  # type: ignore
+            class DoesNotInheritPlugin:  # pylint: disable=unused-variable
+                pass
+
+    def test_plugin_registration_fails_wrong_class_for_category(self):
+        class ExpectedClassForDummyCategory(plugin_core.Plugin):
+            pass
+
+        plugin_core.register_plugin_category("dummy_category", expected_class=ExpectedClassForDummyCategory)
+
+        with pytest.raises(TypeError, match=".*[Ee]xpected.*subclass.*ExpectedClassForDummyCategory.*"):
+
+            @plugin_core.register_plugin(name="dummy_plugin", category="dummy_category")
+            class UnexpectedClassPlugin(plugin_core.Plugin):  # pylint: disable=unused-variable
+                pass
+
+    def test_plugin_registration_fails_duplicate(
+        self,
+    ):
+        plugin_core.register_plugin_category("dummy_category", expected_class=plugin_core.Plugin)
+
+        @plugin_core.register_plugin(name="dummy_plugin", category="dummy_category")
+        class DummyPlugin(plugin_core.Plugin):  # pylint: disable=unused-variable
+            pass
+
+        with pytest.raises(TypeError, match=".*[Pp]lugin.*name.*already registered.*"):
+
+            @plugin_core.register_plugin(name="dummy_plugin", category="dummy_category")
+            class DummyPluginDuplicate(plugin_core.Plugin):  # pylint: disable=unused-variable
+                pass
+
+
+category_a = "category_a"
+category_b_x = "category_b.x"
+category_b_y = "category_b.y"
+CategoryAExpectedClass = Mock()
+CategoryBXExpectedClass = Mock()
+CategoryBYExpectedClass = Mock()
+PluginA1 = Mock(category=category_a)
+PluginA1.name = "plugin_a1"
+PluginA2 = Mock(category=category_a)
+PluginA2.name = "plugin_a2"
+PluginBX1 = Mock(category=category_b_x)
+PluginBX1.name = "plugin_bx1"
+PluginBX2 = Mock(category=category_b_x)
+PluginBX2.name = "plugin_bx2"
+PluginBY1 = Mock(category=category_b_y)
+PluginBY1.name = "plugin_by1"
+PluginBY2 = Mock(category=category_b_y)
+PluginBY2.name = "plugin_by2"
+
+
+class TestPluginLoader:
+    @pytest.fixture
+    def always_patch_module_in_this_test_class(self, patch_plugins_core_module):
+        pass
+
+    @pytest.fixture(autouse=True)
+    def pre_fill_registry(self, always_patch_module_in_this_test_class):
+        plugin_core.PLUGIN_CATEGORY_REGISTRY = {
+            category_a: CategoryAExpectedClass,
+            category_b_x: CategoryBXExpectedClass,
+            category_b_y: CategoryBYExpectedClass,
+        }
+        plugin_core.PLUGIN_REGISTRY = {
+            f"{PluginA1.category}.{PluginA1.name}": PluginA1,
+            f"{PluginA2.category}.{PluginA2.name}": PluginA2,
+            f"{PluginBX1.category}.{PluginBX1.name}": PluginBX1,
+            f"{PluginBX2.category}.{PluginBX2.name}": PluginBX2,
+            f"{PluginBY1.category}.{PluginBY1.name}": PluginBY1,
+            f"{PluginBY2.category}.{PluginBY2.name}": PluginBY2,
+        }
+
+    def test_list(self):
+        loader = plugin_core.PluginLoader()
+        listed = loader.list()
+
+        assert listed == {
+            "category_a": ["plugin_a1", "plugin_a2"],
+            "category_b": {
+                "x": ["plugin_bx1", "plugin_bx2"],
+                "y": ["plugin_by1", "plugin_by2"],
+            },
+        }
+
+    def test_list_classes(self):
+        loader = plugin_core.PluginLoader()
+        listed = loader.list_classes()
+
+        assert listed == {
+            "category_a": [PluginA1, PluginA2],
+            "category_b": {
+                "x": [PluginBX1, PluginBX2],
+                "y": [PluginBY1, PluginBY2],
+            },
+        }
+
+    def test_list_fqns(self):
+        loader = plugin_core.PluginLoader()
+        listed = loader.list_fqns()
+
+        assert listed == [
+            "category_a.plugin_a1",
+            "category_a.plugin_a2",
+            "category_b.x.plugin_bx1",
+            "category_b.x.plugin_bx2",
+            "category_b.y.plugin_by1",
+            "category_b.y.plugin_by2",
+        ]
+
+    def test_get(self):
+        loader = plugin_core.PluginLoader()
+
+        plugin_a2_instance = loader.get(  # pylint: disable=unused-variable  # noqa: F841
+            "category_a.plugin_a2", "arg", kwarg="kwarg"
+        )
+        PluginA2.assert_called_once_with("arg", kwarg="kwarg")
+
+        plugin_by2_instance = loader.get(  # pylint: disable=unused-variable  # noqa: F841
+            name="category_b.y.plugin_by2", kwarg="kwarg"
+        )
+        PluginBY2.assert_called_once_with(kwarg="kwarg")
+
+    def test_get_class(self):
+        loader = plugin_core.PluginLoader()
+
+        plugin_a2_class = loader.get_class("category_a.plugin_a2")
+        assert plugin_a2_class == PluginA2
+
+        plugin_bx1_class = loader.get_class("category_b.x.plugin_bx1")
+        assert plugin_bx1_class == PluginBX1
+
+    def test_get_fails_no_such_plugin(self):
+        loader = plugin_core.PluginLoader()
+
+        with pytest.raises(ValueError, match=".*[Pp]lugin.*not.*exist.*"):
+            loader.get_class("category_a.no_such_plugin")
diff --git a/tests/plugins/core/test_plugins_core_utils.py b/tests/plugins/core/test_plugins_core_utils.py
new file mode 100644
index 00000000..f5fa51f7
--- /dev/null
+++ b/tests/plugins/core/test_plugins_core_utils.py
@@ -0,0 +1,72 @@
+import pytest
+
+import tempor.plugins.core.utils as utils
+
+
+@pytest.mark.parametrize(
+    "initial_dict, key_path, final_dict",
+    [
+        (
+            {"a": 1},
+            "b",
+            {"a": 1, "b": 99},
+        ),
+        (
+            {"a": 1},
+            "b.b_1",
+            {"a": 1, "b": {"b_1": 99}},
+        ),
+        (
+            {"a": 1, "b": {"b_1": 2}},
+            "b.b_2",
+            {"a": 1, "b": {"b_1": 2, "b_2": 99}},
+        ),
+        (
+            {"a": 1, "b": {"b_1": 2, "b_2": 3}},
+            "b.b_2",
+            {"a": 1, "b": {"b_1": 2, "b_2": 99}},
+        ),
+    ],
+)
+def test_add_by_dot_path(initial_dict, key_path, final_dict):
+    value_to_add = 99
+    dictionary = utils.add_by_dot_path(initial_dict, key_path, value_to_add)
+    assert dictionary == final_dict
+
+
+@pytest.mark.parametrize(
+    "dictionary, key_path",
+    [
+        (
+            {"a": 99},
+            "a",
+        ),
+        (
+            {"a": 1, "b": {"b_1": 99}},
+            "b.b_1",
+        ),
+    ],
+)
+def test_get_by_dot_path(dictionary, key_path):
+    assert utils.get_by_dot_path(dictionary, key_path) == 99
+
+
+@pytest.mark.parametrize(
+    "initial_dict, key_path, final_dict",
+    [
+        (
+            {"a": [1], "b": {"b_1": [2]}},
+            "b.b_2",
+            {"a": [1], "b": {"b_1": [2], "b_2": [99]}},
+        ),
+        (
+            {"a": [1], "b": {"b_1": [2]}},
+            "b.b_1",
+            {"a": [1], "b": {"b_1": [2, 99]}},
+        ),
+    ],
+)
+def test_append_by_dot_path(initial_dict, key_path, final_dict):
+    value_to_add = 99
+    dictionary = utils.append_by_dot_path(initial_dict, key_path, value_to_add)
+    assert dictionary == final_dict
diff --git a/tests/plugins/test_plugin_loader.py b/tests/plugins/test_plugin_loader.py
new file mode 100644
index 00000000..4bde8b25
--- /dev/null
+++ b/tests/plugins/test_plugin_loader.py
@@ -0,0 +1,13 @@
+from tempor.plugins import plugin_loader
+
+
+def test_tempor_plugin_loader_contents():
+    all_plugins = plugin_loader.list()
+
+    # Do some checks that expected plugins have been registered.
+    # Update as plugins get added / reorganized.
+    assert "preprocessing" in all_plugins
+    assert "imputation" in all_plugins["preprocessing"]
+    assert "scaling" in all_plugins["preprocessing"]
+    assert "nop_imputer" in all_plugins["preprocessing"]["imputation"]
+    assert "nop_scaler" in all_plugins["preprocessing"]["scaling"]