From 6e0ddb5a949545b6cf37ff772718dc0b641aa3be Mon Sep 17 00:00:00 2001 From: dennywu2966 Date: Wed, 13 May 2026 23:42:38 +0800 Subject: [PATCH] Add pet adoption recommender domain --- README.md | 25 ++- arch__PetAdoptionRecommender.py | 14 ++ pet_adoption_domain.py | 341 ++++++++++++++++++++++++++++++ pet_adoption_recommender.py | 110 ++++++++++ tests/test_pet_adoption_domain.py | 79 +++++++ 5 files changed, 568 insertions(+), 1 deletion(-) create mode 100644 arch__PetAdoptionRecommender.py create mode 100644 pet_adoption_domain.py create mode 100644 pet_adoption_recommender.py create mode 100644 tests/test_pet_adoption_domain.py diff --git a/README.md b/README.md index 26cb9cc..0cc1fae 100644 --- a/README.md +++ b/README.md @@ -55,10 +55,33 @@ You're done! Access the app at `localhost:8501` in your browser. The recommender system works by loading a set of random video links. Once the user hits the Run button, a video will be shown, and the system will suggest whether it recommends the video or not. The user can then provide feedback using "pain" or "pleasure" signals to guide the recommendation process. Based on this feedback, the system adjusts its responses and suggests another video. This cycle continues, allowing for more accurate and personalized recommendations over time. +## Pet Adoption Domain Demo + +This fork also includes a pet adoption recommender domain that maps adopter context to a local catalog of adoptable pets. It keeps the same AO-compatible input shape as the original recommender, `arch_i = [3, 2, 1, 2]`, using: + +- 3 bits for species +- 2 bits for energy level +- 1 bit for child-friendly fit +- 2 bits for the adopter's goal + +The demo can run without private AO packages through a deterministic fallback ranker: + +```bash +python3 pet_adoption_recommender.py --species cat --home apartment --activity low --goal companion +python3 pet_adoption_recommender.py --species dog --home house --activity high --children --experience experienced --goal active +``` + +To use the Streamlit UI: + +```bash +streamlit run pet_adoption_recommender.py +``` + +The domain code lives in `pet_adoption_domain.py`, the demo entrypoint is `pet_adoption_recommender.py`, and the AO architecture is defined in `arch__PetAdoptionRecommender.py`. + ## Contributing Fork the repository, make your changes, and submit a pull request for review. - diff --git a/arch__PetAdoptionRecommender.py b/arch__PetAdoptionRecommender.py new file mode 100644 index 0000000..b305a05 --- /dev/null +++ b/arch__PetAdoptionRecommender.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +"""AO architecture for the pet adoption recommender domain.""" + +import ao_arch as ar + +description = "Pet Adoption Recommender" + +# species_encoding + energy_encoding + child_friendly + adoption_goal +arch_i = [3, 2, 1, 2] +arch_z = [10] +arch_c = [] +connector_function = "full_conn" + +arch = ar.Arch(arch_i, arch_z, arch_c, connector_function, description) diff --git a/pet_adoption_domain.py b/pet_adoption_domain.py new file mode 100644 index 0000000..5432e06 --- /dev/null +++ b/pet_adoption_domain.py @@ -0,0 +1,341 @@ +"""Pet adoption recommendation domain for the AO recommender demo.""" + +from __future__ import annotations + +from copy import deepcopy + + +SPECIES_ENCODINGS = { + "any": [0, 0, 0], + "dog": [0, 0, 1], + "cat": [0, 1, 0], + "rabbit": [0, 1, 1], + "bird": [1, 0, 0], + "small_mammal": [1, 0, 1], +} + +ENERGY_ENCODINGS = { + "low": [0, 0], + "medium": [0, 1], + "high": [1, 1], +} + +GOAL_ENCODINGS = { + "companion": [0, 0], + "active": [0, 1], + "family": [1, 0], + "special_needs": [1, 1], +} + +PET_CATALOG = [ + { + "id": "luna", + "name": "Luna", + "species": "cat", + "age": "adult", + "energy": "low", + "home_types": ["apartment", "house"], + "good_with_children": False, + "experience_required": "new", + "goals": ["companion"], + "traits": ["quiet", "lap_cat", "independent"], + "description": "A calm adult cat who prefers quiet rooms and predictable routines.", + }, + { + "id": "ranger", + "name": "Ranger", + "species": "dog", + "age": "young", + "energy": "high", + "home_types": ["house"], + "good_with_children": True, + "experience_required": "experienced", + "goals": ["active"], + "traits": ["hiking", "fetch", "training"], + "description": "A trail-ready dog who wants structured exercise and confident handling.", + }, + { + "id": "poppy", + "name": "Poppy", + "species": "dog", + "age": "adult", + "energy": "medium", + "home_types": ["house"], + "good_with_children": True, + "experience_required": "new", + "goals": ["family"], + "traits": ["gentle", "patient", "yard_play"], + "description": "A gentle family dog who enjoys daily walks and backyard play.", + }, + { + "id": "milo", + "name": "Milo", + "species": "cat", + "age": "young", + "energy": "low", + "home_types": ["apartment", "house"], + "good_with_children": True, + "experience_required": "new", + "goals": ["family", "companion"], + "traits": ["playful", "social", "easygoing"], + "description": "A social cat who is comfortable with gentle children and mixed routines.", + }, + { + "id": "sage", + "name": "Sage", + "species": "rabbit", + "age": "adult", + "energy": "medium", + "home_types": ["apartment", "house"], + "good_with_children": True, + "experience_required": "new", + "goals": ["companion", "family"], + "traits": ["litter_trained", "curious", "soft_handling"], + "description": "A litter-trained rabbit for adopters who can provide supervised floor time.", + }, + { + "id": "kiwi", + "name": "Kiwi", + "species": "bird", + "age": "adult", + "energy": "medium", + "home_types": ["apartment", "house"], + "good_with_children": False, + "experience_required": "experienced", + "goals": ["companion"], + "traits": ["vocal", "enrichment", "routine"], + "description": "A bright companion bird who needs enrichment and an experienced caretaker.", + }, + { + "id": "peanut", + "name": "Peanut", + "species": "small_mammal", + "age": "senior", + "energy": "low", + "home_types": ["apartment", "house"], + "good_with_children": True, + "experience_required": "new", + "goals": ["companion", "special_needs"], + "traits": ["senior", "low_space", "gentle"], + "description": "A senior guinea pig who fits small homes and calm handling.", + }, + { + "id": "nova", + "name": "Nova", + "species": "dog", + "age": "adult", + "energy": "medium", + "home_types": ["apartment", "house"], + "good_with_children": False, + "experience_required": "experienced", + "goals": ["special_needs", "companion"], + "traits": ["deaf", "visual_cues", "loyal"], + "description": "A deaf dog who thrives with visual cue training and a steady home.", + }, + { + "id": "maple", + "name": "Maple", + "species": "cat", + "age": "senior", + "energy": "low", + "home_types": ["apartment", "house"], + "good_with_children": True, + "experience_required": "new", + "goals": ["special_needs", "family"], + "traits": ["senior", "medication", "affectionate"], + "description": "An affectionate senior cat whose adopter can keep a simple medication routine.", + }, + { + "id": "dash", + "name": "Dash", + "species": "rabbit", + "age": "young", + "energy": "high", + "home_types": ["house"], + "good_with_children": True, + "experience_required": "experienced", + "goals": ["active", "family"], + "traits": ["agility", "enrichment", "supervised_play"], + "description": "A high-energy rabbit for adopters who enjoy training and enrichment work.", + }, + { + "id": "opal", + "name": "Opal", + "species": "cat", + "age": "adult", + "energy": "medium", + "home_types": ["apartment"], + "good_with_children": False, + "experience_required": "new", + "goals": ["companion"], + "traits": ["window_watching", "solo_pet", "curious"], + "description": "A curious apartment cat who prefers being the only pet in a calm home.", + }, + { + "id": "buddy", + "name": "Buddy", + "species": "dog", + "age": "senior", + "energy": "low", + "home_types": ["apartment", "house"], + "good_with_children": True, + "experience_required": "new", + "goals": ["companion", "family"], + "traits": ["senior", "short_walks", "calm"], + "description": "A senior dog who wants short walks, soft beds, and a relaxed family.", + }, +] + + +def get_pet_by_id(pet_id): + """Return a copy of a catalog pet by id.""" + for pet in PET_CATALOG: + if pet["id"] == pet_id: + return deepcopy(pet) + raise ValueError(f"Unknown pet id: {pet_id}") + + +def encode_pet(pet, adoption_goal="companion"): + """Encode pet attributes plus adopter goal into the 8-bit AO input shape.""" + _validate_option("species", pet["species"], SPECIES_ENCODINGS, allow_any=False) + _validate_option("energy", pet["energy"], ENERGY_ENCODINGS) + _validate_option("adoption_goal", adoption_goal, GOAL_ENCODINGS) + + child_bit = [1 if pet["good_with_children"] else 0] + return ( + SPECIES_ENCODINGS[pet["species"]] + + ENERGY_ENCODINGS[pet["energy"]] + + child_bit + + GOAL_ENCODINGS[adoption_goal] + ) + + +def rank_pets( + preferred_species="any", + home_type="apartment", + activity_level="medium", + has_children=False, + experience_level="new", + adoption_goal="companion", + feedback=None, + limit=5, +): + """Rank adoptable pets for a household context using deterministic scoring.""" + _validate_option("preferred_species", preferred_species, SPECIES_ENCODINGS) + _validate_option("activity_level", activity_level, ENERGY_ENCODINGS) + _validate_option("adoption_goal", adoption_goal, GOAL_ENCODINGS) + _validate_choice("home_type", home_type, {"apartment", "house"}) + _validate_choice("experience_level", experience_level, {"new", "experienced"}) + + feedback = feedback or [] + scored = [] + for pet in PET_CATALOG: + pet_copy = deepcopy(pet) + score = _score_pet( + pet_copy, + preferred_species, + home_type, + activity_level, + has_children, + experience_level, + adoption_goal, + ) + score += _score_feedback_similarity(pet_copy, feedback) + pet_copy["score"] = score + pet_copy["binary_input"] = encode_pet(pet_copy, adoption_goal) + scored.append(pet_copy) + + scored.sort(key=lambda item: (-item["score"], item["name"])) + return scored[:limit] + + +def format_recommendations(recommendations): + """Create compact CLI output for ranked pets.""" + lines = [] + for index, pet in enumerate(recommendations, start=1): + traits = ", ".join(pet["traits"][:3]) + lines.append( + f"{index}. {pet['name']} ({pet['species']}, score {pet['score']}): " + f"{pet['description']} Traits: {traits}." + ) + return "\n".join(lines) + + +def _score_pet( + pet, + preferred_species, + home_type, + activity_level, + has_children, + experience_level, + adoption_goal, +): + score = 0 + + if preferred_species != "any": + score += 5 if pet["species"] == preferred_species else -3 + + score += 4 if home_type in pet["home_types"] else -6 + + if pet["energy"] == activity_level: + score += 4 + elif _energy_distance(pet["energy"], activity_level) == 1: + score += 1 + else: + score -= 2 + + if has_children: + score += 4 if pet["good_with_children"] else -7 + else: + score += 1 if not pet["good_with_children"] else 0 + + if pet["experience_required"] == "new": + score += 3 + elif experience_level == "experienced": + score += 3 + else: + score -= 5 + + if adoption_goal in pet["goals"]: + score += 5 + elif adoption_goal == "family" and pet["good_with_children"]: + score += 2 + elif adoption_goal == "active" and pet["energy"] == "high": + score += 2 + + return score + + +def _score_feedback_similarity(candidate, feedback): + score = 0 + for event in feedback: + pet_id = event.get("pet_id") + if pet_id is None: + continue + + reference_pet = get_pet_by_id(pet_id) + direction = 1 if event.get("liked", True) else -1 + if candidate["id"] == reference_pet["id"]: + score += 8 * direction + if candidate["species"] == reference_pet["species"]: + score += 2 * direction + if candidate["energy"] == reference_pet["energy"]: + score += direction + if set(candidate["goals"]) & set(reference_pet["goals"]): + score += direction + return score + + +def _energy_distance(first, second): + order = {"low": 0, "medium": 1, "high": 2} + return abs(order[first] - order[second]) + + +def _validate_option(name, value, options, allow_any=True): + if value not in options or (value == "any" and not allow_any): + raise ValueError(f"Invalid {name}: {value}") + + +def _validate_choice(name, value, choices): + if value not in choices: + raise ValueError(f"Invalid {name}: {value}") diff --git a/pet_adoption_recommender.py b/pet_adoption_recommender.py new file mode 100644 index 0000000..298db49 --- /dev/null +++ b/pet_adoption_recommender.py @@ -0,0 +1,110 @@ +"""CLI and Streamlit demo for the pet adoption recommender domain.""" + +from __future__ import annotations + +import argparse + +from pet_adoption_domain import format_recommendations, rank_pets + + +def recommend( + preferred_species="any", + home_type="apartment", + activity_level="medium", + has_children=False, + experience_level="new", + adoption_goal="companion", + feedback=None, + limit=5, +): + """Return ranked pets through the deterministic fallback recommender.""" + return rank_pets( + preferred_species=preferred_species, + home_type=home_type, + activity_level=activity_level, + has_children=has_children, + experience_level=experience_level, + adoption_goal=adoption_goal, + feedback=feedback, + limit=limit, + ) + + +def run_cli(argv=None): + parser = argparse.ArgumentParser(description="Recommend adoptable pets for a home context.") + parser.add_argument("--species", default="any", choices=["any", "dog", "cat", "rabbit", "bird", "small_mammal"]) + parser.add_argument("--home", default="apartment", choices=["apartment", "house"]) + parser.add_argument("--activity", default="medium", choices=["low", "medium", "high"]) + parser.add_argument("--children", action="store_true") + parser.add_argument("--experience", default="new", choices=["new", "experienced"]) + parser.add_argument( + "--goal", + default="companion", + choices=["companion", "active", "family", "special_needs"], + ) + parser.add_argument("--liked-pet", action="append", default=[]) + parser.add_argument("--limit", type=int, default=5) + args = parser.parse_args(argv) + + feedback = [{"pet_id": pet_id, "liked": True} for pet_id in args.liked_pet] + recommendations = recommend( + preferred_species=args.species, + home_type=args.home, + activity_level=args.activity, + has_children=args.children, + experience_level=args.experience, + adoption_goal=args.goal, + feedback=feedback, + limit=args.limit, + ) + print(format_recommendations(recommendations)) + + +def run_streamlit(): + import streamlit as st + + st.title("Pet Adoption Recommender") + st.caption("A local AO-compatible recommender domain for matching pets to adopter context.") + + preferred_species = st.selectbox("Species", ["any", "dog", "cat", "rabbit", "bird", "small_mammal"]) + home_type = st.radio("Home type", ["apartment", "house"], horizontal=True) + activity_level = st.select_slider("Activity level", options=["low", "medium", "high"], value="medium") + has_children = st.checkbox("Children in household") + experience_level = st.radio("Adopter experience", ["new", "experienced"], horizontal=True) + adoption_goal = st.selectbox("Adoption goal", ["companion", "active", "family", "special_needs"]) + liked_pet = st.multiselect( + "Recommend more like", + ["luna", "ranger", "poppy", "milo", "sage", "kiwi", "peanut", "nova", "maple", "dash", "opal", "buddy"], + ) + + feedback = [{"pet_id": pet_id, "liked": True} for pet_id in liked_pet] + recommendations = recommend( + preferred_species=preferred_species, + home_type=home_type, + activity_level=activity_level, + has_children=has_children, + experience_level=experience_level, + adoption_goal=adoption_goal, + feedback=feedback, + ) + + for pet in recommendations: + st.subheader(f"{pet['name']} - {pet['species']} - score {pet['score']}") + st.write(pet["description"]) + st.write("AO input:", pet["binary_input"]) + st.write("Traits:", ", ".join(pet["traits"])) + + +def _running_in_streamlit(): + try: + from streamlit.runtime.scriptrunner import get_script_run_ctx + except Exception: + return False + return get_script_run_ctx() is not None + + +if __name__ == "__main__": + if _running_in_streamlit(): + run_streamlit() + else: + run_cli() diff --git a/tests/test_pet_adoption_domain.py b/tests/test_pet_adoption_domain.py new file mode 100644 index 0000000..60999b4 --- /dev/null +++ b/tests/test_pet_adoption_domain.py @@ -0,0 +1,79 @@ +import unittest + +from pet_adoption_domain import ( + encode_pet, + get_pet_by_id, + rank_pets, +) + + +class PetAdoptionDomainTest(unittest.TestCase): + def test_encode_pet_returns_ao_compatible_eight_bit_input(self): + pet = get_pet_by_id("ranger") + + self.assertEqual( + encode_pet(pet, adoption_goal="active"), + [0, 0, 1, 1, 1, 1, 0, 1], + ) + + def test_rank_pets_matches_apartment_companion_context(self): + recommendations = rank_pets( + preferred_species="cat", + home_type="apartment", + activity_level="low", + has_children=False, + experience_level="new", + adoption_goal="companion", + limit=3, + ) + + self.assertEqual(recommendations[0]["id"], "luna") + self.assertEqual(recommendations[0]["species"], "cat") + self.assertGreaterEqual(recommendations[0]["score"], recommendations[1]["score"]) + + def test_rank_pets_matches_active_dog_context(self): + recommendations = rank_pets( + preferred_species="dog", + home_type="house", + activity_level="high", + has_children=True, + experience_level="experienced", + adoption_goal="active", + limit=3, + ) + + self.assertEqual(recommendations[0]["id"], "ranger") + self.assertIn("hiking", recommendations[0]["traits"]) + + def test_feedback_can_change_recommendation_order(self): + baseline = rank_pets( + preferred_species="any", + home_type="house", + activity_level="medium", + has_children=True, + experience_level="new", + adoption_goal="family", + limit=2, + ) + + with_feedback = rank_pets( + preferred_species="any", + home_type="house", + activity_level="medium", + has_children=True, + experience_level="new", + adoption_goal="family", + feedback=[{"pet_id": "milo", "liked": True}], + limit=2, + ) + + self.assertNotEqual(baseline[0]["id"], "milo") + self.assertEqual(with_feedback[0]["id"], "milo") + + def test_invalid_species_is_rejected(self): + with self.assertRaises(ValueError): + rank_pets(preferred_species="dragon") + + +if __name__ == "__main__": + unittest.main()