Skip to content

Commit

Permalink
Introduce first Problems and Solutions
Browse files Browse the repository at this point in the history
  • Loading branch information
matejak committed Mar 6, 2024
1 parent 65db879 commit d1b1656
Show file tree
Hide file tree
Showing 6 changed files with 352 additions and 4 deletions.
2 changes: 0 additions & 2 deletions estimage/entities/card.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import re
import typing
import enum
import dataclasses
import datetime

Expand Down
8 changes: 6 additions & 2 deletions estimage/entities/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,12 @@ def __init__(self):
self.name_composition_map = dict()

def use_composition(self, composition: Composition):
self.main_composition = composition
self.main_composition.name = ""
if composition.name:
self.main_composition = Composition("")
self.main_composition.add_composition(composition)
else:
self.main_composition = composition
self.main_composition.name = ""

self.name_result_map = dict()
self.name_composition_map = dict()
Expand Down
100 changes: 100 additions & 0 deletions estimage/problems/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import dataclasses
import typing

import numpy as np

from ..entities.card import BaseCard
from ..entities.model import EstiModel


@dataclasses.dataclass(init=False)
class Problem:
description: str
affected_cards_names: list[str]
tags: set[str]

def __init__(self):
self.description = ""
self.affected_cards_names = []
self.tags = frozenset()

def add_tag(self, tag):
self.tags = frozenset(self.tags.union([tag]))


class ProblemDetector:
POINT_THRESHOLD = 0.4

def __init__(self, model: EstiModel, cards: typing.Iterable[BaseCard]):
self.model = model
self.cards = cards
self.problems = []

self._get_problems()

def _get_problems(self):
for card in self.cards:
self._get_card_problem(card)

def _get_card_problem(self, card):
self._analyze_inconsistent_record(card)

def _numbers_differ_significantly(self, lhs, rhs):
if np.abs(lhs - rhs) > self.POINT_THRESHOLD:
return True

def _create_and_add_card_problem(self, card):
problem = Problem()
problem.affected_cards_names.append(card.name)
self.problems.append(problem)
return problem

def _card_is_not_estimated_but_has_children(self, card):
return card.children and card.point_cost == 0

def _treat_inconsistent_estimate(self, card, computed_nominal_cost, recorded_cost, expected_computed_cost):
problem = self._create_and_add_card_problem(card)
problem.add_tag("inconsistent_estimate")
if computed_nominal_cost == 0:
self._inconsistent_card_missing_estimates(problem, card, recorded_cost)
else:
self._inconsistent_card_differing_estimate(problem, card, recorded_cost, expected_computed_cost)

def _card_has_no_children_with_children(self, card):
for child in card.children:
if child.children:
return False
return True

def _inconsistent_card_missing_estimates(self, problem, card, recorded_cost):
problem.description = (
f"'{card.name}' has inconsistent recorded point cost of {recorded_cost:.2g}, "
f"as its children appear to lack estimations."
)
problem.add_tag("missing_estimates")
if self._card_has_no_children_with_children(card):
problem.add_tag("childless_children")

def _inconsistent_card_differing_estimate(self, problem, card, recorded_cost, expected_computed_cost):
if expected_computed_cost < recorded_cost:
problem.add_tag("sum_of_children_lower")
problem.description = (
f"'{card.name}' has inconsistent recorded point cost of {recorded_cost:.2g}, "
f"while the deduced cost is {expected_computed_cost:.2g}"
)

def _analyze_inconsistent_record(self, card):
recorded_cost = card.point_cost
computed_remaining_cost = self.model.remaining_point_estimate_of(card.name).expected
computed_nominal_cost = self.model.nominal_point_estimate_of(card.name).expected
expected_computed_cost = computed_nominal_cost
if card.children:
expected_computed_cost = computed_remaining_cost

if self._card_is_not_estimated_but_has_children(card):
return

if not self._numbers_differ_significantly(recorded_cost, expected_computed_cost):
return

self._treat_inconsistent_estimate(card, computed_nominal_cost, recorded_cost, expected_computed_cost)
88 changes: 88 additions & 0 deletions estimage/solutions/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import dataclasses
import typing

from ..entities.card import BaseCard
from ..problems import Problem


@dataclasses.dataclass(init=False)
class Solution:
card_name: str

def __init__(self):
self.card_name = ""

def prime(self, cards: typing.Iterable[BaseCard]):
raise NotImplementedError()


class SolutionByUpdating(Solution):
end_value: float

def __init__(self):
super().__init__()
self.end_value = None


class SolutionByUpdatingChildren(SolutionByUpdating):
action = "update_children_points"

def prime(self, cards: typing.Iterable[BaseCard]):
my_card = [c for c in cards if c.name == self.card_name][0]
self.end_value = my_card.point_cost / len(my_card.children)
pass


class SolutionByUpdatingSelf(SolutionByUpdating):
action = "update_points"

def prime(self, cards: typing.Iterable[BaseCard]):
pass


class ProblemSolver:
SOLUTIONS = []

def get_solutions(self, problems: typing.Iterable[Problem]):
solutions = []
for problem in problems:
solution = self.get_solution_of(problem)
if solution:
solutions.append(solution)
return solutions

def get_solution_of(self, problem: Problem):
for solution in self.SOLUTIONS:
ret = solution(problem)
if ret:
return ret


def problem_solution(func):
ProblemSolver.SOLUTIONS.append(func)
return func


@problem_solution
def get_solution_of_inconsistent_parent(problem: Problem):
if "inconsistent_estimate" not in problem.tags:
return
if "missing_estimates" in problem.tags:
return
ret = SolutionByUpdatingSelf()
ret.card_name = problem.affected_cards_names[0]
return ret


@problem_solution
def get_solution_of_inconsistent_children(problem: Problem):
if "inconsistent_estimate" not in problem.tags:
return
if "missing_estimates" not in problem.tags:
return
if "childless_children" not in problem.tags:
return

ret = SolutionByUpdatingChildren()
ret.card_name = problem.affected_cards_names[0]
return ret
102 changes: 102 additions & 0 deletions tests/test_problems.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import pytest

import estimage.problems as tm


@pytest.fixture
def cards_one_two():
card_two = tm.BaseCard("two")
card_two.status = "todo"
card_two.point_cost = 1

card_one = tm.BaseCard("one")
card_one.status = "todo"
card_one.point_cost = 1
card_one.add_element(card_two)
return [card_one, card_two]


def test_model_picks_no_problem():
card_one = tm.BaseCard("one")
card_one.status = "todo"
card_one.point_cost = 1

problems = get_problems_of_cards([card_one])
assert len(problems) == 0


def get_problems_of_cards(cards):
model = tm.EstiModel()
comp = cards[0].to_tree(cards)
model.use_composition(comp)
problems = tm.ProblemDetector(model, cards)
return problems.problems


def get_problem_of_cards(cards):
problems = get_problems_of_cards(cards)
assert len(problems) == 1
return problems[0]


def test_model_finds_no_problem(cards_one_two):
assert len(get_problems_of_cards(cards_one_two)) == 0


def test_model_tolerates_no_estimate_of_parent(cards_one_two):
card_one, card_two = cards_one_two
card_one.point_cost = 0
assert len(get_problems_of_cards(cards_one_two)) == 0


def test_model_tolerates_small_inconsistency(cards_one_two):
card_one, card_two = cards_one_two
card_two.point_cost = 1.2
assert len(get_problems_of_cards(cards_one_two)) == 0


def test_model_notices_basic_inconsistency(cards_one_two):
card_one, card_two = cards_one_two
card_two.point_cost = 2
problem = get_problem_of_cards(cards_one_two)
assert "inconsistent_estimate" in problem.tags
assert "sum_of_children_lower" not in problem.tags
assert "one" in problem.description
assert "is 2" in problem.description
assert "of 1" in problem.description


def test_model_notices_inconsistency_maybe_caused_by_progress(cards_one_two):
card_one, card_two = cards_one_two
card_two.point_cost = 0.5
problem = get_problem_of_cards(cards_one_two)
assert "inconsistent_estimate" in problem.tags
assert "sum_of_children_lower" in problem.tags


def test_model_notices_children_not_estimated(cards_one_two):
card_one, card_two = cards_one_two

card_two.status = "done"
problem = get_problem_of_cards(cards_one_two)
assert "inconsistent_estimate" in problem.tags
assert "missing_estimates" not in problem.tags
assert "one" in problem.description
assert "not estimated" not in problem.description

card_two.point_cost = 0
problem = get_problem_of_cards(cards_one_two)
assert "one" in problem.description
assert "inconsistent_estimate" in problem.tags
assert "missing_estimates" in problem.tags


def test_model_finds_status_problem(cards_one_two):
card_one, card_two = cards_one_two
card_two.status = "done"
problems = get_problems_of_cards([card_two])
assert len(problems) == 0

card_two.point_cost = card_one.point_cost
problem = get_problem_of_cards(cards_one_two)
assert "one" in problem.affected_cards_names
56 changes: 56 additions & 0 deletions tests/test_solutions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import pytest

import estimage.solutions as tm
import estimage.problems as problems

import test_problems as ut
from test_problems import cards_one_two


@pytest.fixture
def solver():
return tm.ProblemSolver()


def test_no_problem_no_solution(solver):
assert len(solver.get_solutions([])) == 0


def test_basic_inconsistency_solution(solver, cards_one_two):
card_one, card_two = cards_one_two
card_two.point_cost = 2

problem = ut.get_problem_of_cards(cards_one_two)
solutions = solver.get_solutions([problem])
assert len(solutions) == 1
solution = solutions[0]
assert solution.action == "update_points"
assert solution.card_name == "one"
solution.prime([card_one])
# assert solution.end_value == card_two.point_cost


def test_update_children_inconsistency_solution(solver, cards_one_two):
card_one, card_two = cards_one_two
card_two.point_cost = 0
card_three = tm.BaseCard("three")
card_one.add_element(card_three)

problem = ut.get_problem_of_cards(cards_one_two)
solutions = solver.get_solutions([problem])
assert len(solutions) == 1
solution = solutions[0]
assert solution.action == "update_children_points"
assert solution.card_name == "one"
solution.prime([card_one])
assert solution.end_value == card_one.point_cost / 2.0


def test_update_complex_children_no_solution(solver, cards_one_two):
card_one, card_two = cards_one_two
card_three = problems.BaseCard("three")
card_two.add_element(card_three)

problem = ut.get_problem_of_cards([card_one])
solutions = solver.get_solutions([problem])
assert len(solutions) == 0

0 comments on commit d1b1656

Please sign in to comment.